@tokenoftrust/cli 1.4.0-rc.1 → 1.4.0-rc.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/oauth.mjs CHANGED
@@ -84,8 +84,15 @@ export async function registerClient(
84
84
  headers: { "Content-Type": "application/json", Accept: "application/json" },
85
85
  body: JSON.stringify({
86
86
  client_name: CLIENT_NAME,
87
+ // Include the device-code grant: the browserless rendezvous + B3 device flows
88
+ // redeem their device_code at /oauth/token with this grant, so a client
89
+ // registered WITHOUT it gets "unauthorized_client: grant_type is invalid".
90
+ grant_types: [
91
+ "authorization_code",
92
+ "refresh_token",
93
+ "urn:ietf:params:oauth:grant-type:device_code",
94
+ ],
87
95
  redirect_uris: [redirectUri],
88
- grant_types: ["authorization_code", "refresh_token"],
89
96
  response_types: ["code"],
90
97
  token_endpoint_auth_method: "none",
91
98
  scope: SCOPE,
@@ -285,41 +292,50 @@ export async function loginFlow({
285
292
  }
286
293
  }
287
294
 
288
- // ── WS2b: browserless invite-code redemption (`tot login --code <token>`) ─────
295
+ // ── Browserless CLI sign-in RFC 8628 RENDEZVOUS (`tot login --code <handle>`) ──
289
296
  //
290
- // The invited developer pastes the single-use sign-in token their invite minted;
291
- // we hand it to the MCP's redeem endpoint, which validates + BURNS it via ToT and
292
- // returns a normal MCP OAuth grant. No browser, no loopback, no OTP the token IS
293
- // the operator's authorization. Same credentials shape as loginFlow/deviceLoginFlow,
294
- // and the grant is bound to our registered client_id so later silent refreshes go
295
- // through the standard token endpoint like any other session.
297
+ // The invited developer pastes the NON-SECRET rendezvous handle their cockpit
298
+ // rendered. Unlike the retired bearer-redeem (a secret received elsewhere, replayed
299
+ // once), the terminal generates its OWN PKCE key here, ATTACHES only the public
300
+ // challenge to the pending rendezvous, prints a human-verifiable fingerprint, and
301
+ // polls the standard token endpoint (device_code grant + PKCE) until the developer
302
+ // approves that fingerprint in the cockpit. Same credentials shape as
303
+ // loginFlow/deviceLoginFlow, same dynamically-registered client_id, so later silent
304
+ // refreshes go through the standard token endpoint like any other session.
296
305
  //
297
- // Wire contract (CLI → MCP):
298
- // POST {mcp-origin}/oauth/redeem-code (application/json)
299
- // { code: "<invite sign-in token>", client_id, scope? }
300
- // → 200 { access_token, refresh_token, token_type, expires_in, scope }
301
- // → 400 { error, error_description } (invalid_grant | invalid_client)
306
+ // Wire contract (CLI → MCP), all off the MCP origin (from the AS token endpoint):
307
+ // POST /oauth/device/attach (application/json)
308
+ // { rendezvous_code, client_id, code_challenge, code_challenge_method: "S256" }
309
+ // → 200 { device_code, user_fingerprint, interval, expires_in }
310
+ // → 400 { error, error_description } (invalid_grant | invalid_client)
311
+ // POST /oauth/token (form) grant_type=…:device_code, device_code, code_verifier,
312
+ // client_id → RFC 8628 polling until approved/denied/expired.
302
313
  //
303
- // The token→CLI binding is inherent to the single request: the tokens are returned
304
- // only in the direct TLS response to the client that presented the code, and the
305
- // grant is pinned to our dynamically-registered client_id (only that client can
306
- // refresh it). A separate PKCE nonce would add nothing here — there is no second
307
- // exchange step at which a verifier could be presented.
308
-
309
- /** The MCP's redeem endpoint the MCP origin (from the AS token endpoint) + a
310
- * fixed path. Kept beside the flow so the path lives in exactly one place. */
311
- export function redeemCodeEndpoint(mcpUrl, meta) {
314
+ // Device-bound by construction: the pasted handle attaches only a PUBLIC PKCE
315
+ // challenge (intercepting it grants nothing an interceptor's terminal shows a
316
+ // DIFFERENT fingerprint the developer won't approve), and only the terminal holding
317
+ // the matching verifier can redeem the device_code at /oauth/token.
318
+
319
+ /** An MCP device/rendezvous endpoint — the MCP origin (from the AS token endpoint)
320
+ * + a fixed path. Kept beside the flow so the paths live in exactly one place. */
321
+ export function deviceEndpoint(mcpUrl, meta, path) {
312
322
  const origin = meta?.token_endpoint ? new URL(meta.token_endpoint) : new URL(mcpUrl);
313
- return new URL("/oauth/redeem-code", origin).toString();
323
+ return new URL(path, origin).toString();
314
324
  }
315
325
 
316
- /** POST the invite token to the MCP redeem endpoint and return the raw token
317
- * response (→ credentialsFromToken). Throws a clear, non-stack error on rejection. */
318
- export async function redeemInviteCode(redeemEndpoint, { code, clientId, scope = SCOPE }, fetchImpl = fetch) {
319
- const res = await fetchImpl(redeemEndpoint, {
326
+ /** Attach the terminal's PKCE challenge to a pending rendezvous. Returns the raw
327
+ * response { device_code, user_fingerprint, interval, expires_in }. Throws a clear,
328
+ * non-stack error on rejection (an expired / already-used handle). */
329
+ export async function attachRendezvous(attachEndpoint, { rendezvousCode, clientId, challenge }, fetchImpl = fetch) {
330
+ const res = await fetchImpl(attachEndpoint, {
320
331
  method: "POST",
321
332
  headers: { "Content-Type": "application/json", Accept: "application/json" },
322
- body: JSON.stringify({ code, client_id: clientId, scope }),
333
+ body: JSON.stringify({
334
+ rendezvous_code: rendezvousCode,
335
+ client_id: clientId,
336
+ code_challenge: challenge,
337
+ code_challenge_method: "S256",
338
+ }),
323
339
  });
324
340
  const text = await res.text();
325
341
  let body;
@@ -328,31 +344,61 @@ export async function redeemInviteCode(redeemEndpoint, { code, clientId, scope =
328
344
  const detail = [body.error, body.error_description].filter(Boolean).join(" — ");
329
345
  throw new Error(`your sign-in code was not accepted (HTTP ${res.status}${detail ? `: ${detail}` : ""})`);
330
346
  }
331
- if (!body.access_token) throw new Error("the redeem endpoint returned no access_token");
347
+ if (!body.device_code || !body.user_fingerprint) {
348
+ throw new Error("the attach endpoint returned no device_code/fingerprint");
349
+ }
332
350
  return body;
333
351
  }
334
352
 
335
353
  /**
336
- * Run the full browserless redemption and return a persistable credentials record
337
- * — the invite-token sibling of loginFlow()/deviceLoginFlow(), same credentials
338
- * shape, same dynamically-registered client_id (reused when the caller cached one
339
- * for this MCP). Injectable (`fetchImpl`, `now`) so it's testable with no network.
354
+ * Run the full browserless rendezvous sign-in and return a persistable credentials
355
+ * record — the invite sibling of loginFlow()/deviceLoginFlow(), same credentials
356
+ * shape + dynamically-registered client_id (reused when the caller cached one for
357
+ * this MCP). The terminal generates its own PKCE key, attaches, surfaces the
358
+ * fingerprint via `log` for the developer to confirm in the cockpit, then polls the
359
+ * token endpoint until approved. Injectable (`fetchImpl`, `log`, `sleep`, `now`) so
360
+ * it's testable with no network and no real waiting.
340
361
  * @returns {Promise<object>} credentials to hand to writeCredentials()
341
362
  */
342
- export async function redeemCodeFlow({
363
+ export async function rendezvousLoginFlow({
343
364
  mcpUrl,
344
- clientId,
345
365
  code,
346
366
  fetchImpl = fetch,
367
+ log = () => {},
368
+ sleep = delay,
347
369
  now = () => Date.now(),
348
370
  }) {
349
371
  const meta = await discoverMetadata(mcpUrl, fetchImpl);
350
- const resolvedClientId = clientId || (await registerClient(meta.registration_endpoint, LOOPBACK_REDIRECT, fetchImpl));
351
- const token = await redeemInviteCode(
352
- redeemCodeEndpoint(mcpUrl, meta),
353
- { code, clientId: resolvedClientId },
372
+ // Always register a FRESH client for a rendezvous sign-in — do NOT reuse a cached
373
+ // clientId. A client cached by an older CLI build was registered without the
374
+ // device-code grant, so /oauth/token would reject the device grant with
375
+ // "unauthorized_client". Registration is cheap and a rendezvous has no consent
376
+ // screen to re-trigger (unlike the loopback/device flows, which reuse the cache).
377
+ const resolvedClientId = await registerClient(meta.registration_endpoint, LOOPBACK_REDIRECT, fetchImpl);
378
+ const { verifier, challenge } = generatePkce();
379
+
380
+ const attach = await attachRendezvous(
381
+ deviceEndpoint(mcpUrl, meta, "/oauth/device/attach"),
382
+ { rendezvousCode: code, clientId: resolvedClientId, challenge },
354
383
  fetchImpl,
355
384
  );
385
+
386
+ log("");
387
+ log(` Confirm this code in your browser to finish signing in: ${attach.user_fingerprint}`);
388
+ log(" Waiting for you to approve it in Token of Trust …");
389
+
390
+ const token = await pollDeviceToken(
391
+ meta.token_endpoint,
392
+ {
393
+ deviceCode: attach.device_code,
394
+ clientId: resolvedClientId,
395
+ codeVerifier: verifier,
396
+ intervalSec: attach.interval,
397
+ expiresInSec: attach.expires_in,
398
+ },
399
+ fetchImpl,
400
+ { sleep, now },
401
+ );
356
402
  return credentialsFromToken({
357
403
  mcpUrl,
358
404
  clientId: resolvedClientId,
@@ -395,7 +441,7 @@ export async function deviceAuthorize(deviceAuthorizationEndpoint, { clientId, s
395
441
  * normal "keep waiting" responses — so this resolves `{ pending: true }`
396
442
  * (with `slowDown` set) for those instead of throwing.
397
443
  */
398
- async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId }, fetchImpl) {
444
+ async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId, codeVerifier }, fetchImpl) {
399
445
  const res = await fetchImpl(tokenEndpoint, {
400
446
  method: "POST",
401
447
  headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
@@ -403,6 +449,10 @@ async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId }, fetchImp
403
449
  grant_type: "urn:ietf:params:oauth:grant-type:device_code",
404
450
  device_code: deviceCode,
405
451
  client_id: clientId,
452
+ // The rendezvous flow binds the grant to the terminal's PKCE key: the
453
+ // verifier proves this is the same terminal that attached the challenge.
454
+ // Absent for the plain RFC 8628 device flow (no PKCE) — omitted then.
455
+ ...(codeVerifier ? { code_verifier: codeVerifier } : {}),
406
456
  }).toString(),
407
457
  });
408
458
  const text = await res.text();
@@ -428,7 +478,7 @@ async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId }, fetchImp
428
478
  */
429
479
  export async function pollDeviceToken(
430
480
  tokenEndpoint,
431
- { deviceCode, clientId, intervalSec, expiresInSec },
481
+ { deviceCode, clientId, codeVerifier, intervalSec, expiresInSec },
432
482
  fetchImpl = fetch,
433
483
  { sleep = delay, now = () => Date.now() } = {},
434
484
  ) {
@@ -437,7 +487,7 @@ export async function pollDeviceToken(
437
487
  for (;;) {
438
488
  await sleep(intervalMs);
439
489
  if (now() >= deadline) throw new Error("the device code expired before it was approved");
440
- const r = await deviceTokenPoll(tokenEndpoint, { deviceCode, clientId }, fetchImpl);
490
+ const r = await deviceTokenPoll(tokenEndpoint, { deviceCode, clientId, codeVerifier }, fetchImpl);
441
491
  if (!r.pending) return r.token;
442
492
  if (r.slowDown) intervalMs += 5000;
443
493
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The obstacle beacon — a fire-and-forget POST that tells the hosted cockpit a
3
- * `tot start` / `tot checkout` failed, so it can show the exact fix in the bridge
3
+ * `tot start` / `tot clone` failed, so it can show the exact fix in the bridge
4
4
  * strip (obstacle lane, server side already shipped). Best-effort telemetry that
5
5
  * rides ALONGSIDE the house-style `✗ … → next:` error; it must NEVER change,
6
6
  * delay past its timeout, or fail that error path.