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

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "1.4.0-rc.1",
3
+ "version": "1.4.0-rc.2",
4
4
  "description": "Token of Trust developer CLI — check out a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Token of Trust",
@@ -19,7 +19,7 @@
19
19
  *
20
20
  * Dependency-free (node built-ins via oauth.mjs).
21
21
  */
22
- import { loginFlow, deviceLoginFlow, redeemCodeFlow, NoOpenerError } from "../oauth.mjs";
22
+ import { loginFlow, deviceLoginFlow, rendezvousLoginFlow, NoOpenerError } from "../oauth.mjs";
23
23
  import { defaultCredentialsPath, readCredentials, writeCredentials } from "../token-store.mjs";
24
24
  import { openBrowser } from "../open.mjs";
25
25
  import { fail } from "../errors.mjs";
@@ -188,23 +188,25 @@ export async function offerSignIn(mcpUrl, env = process.env, {
188
188
  }
189
189
 
190
190
  /**
191
- * The core of `tot login --code`: run the browserless redemption (POST the invite
192
- * token to the MCP, get a grant back) and cache it, reusing a previously-registered
193
- * client for THIS MCP so we don't re-register on every login.
191
+ * The core of `tot login --code`: run the browserless RENDEZVOUS sign-in (attach the
192
+ * terminal's PKCE challenge to the pasted rendezvous handle, surface the fingerprint,
193
+ * poll until the developer approves it in the cockpit) and cache the grant, reusing a
194
+ * previously-registered client for THIS MCP so we don't re-register on every login.
195
+ * `log` carries the fingerprint + "waiting for approval" lines to the terminal.
194
196
  * @returns {Promise<object>} the credentials written to disk.
195
197
  */
196
- export async function redeemAndCache(mcpUrl, code, env = process.env) {
198
+ export async function redeemAndCache(mcpUrl, code, env = process.env, { log = () => {} } = {}) {
197
199
  const path = defaultCredentialsPath(env);
198
200
  const prior = readCredentials(path);
199
201
  const clientId = prior && prior.mcpUrl === mcpUrl ? prior.clientId : undefined;
200
- const creds = await redeemCodeFlow({ mcpUrl, clientId, code });
202
+ const creds = await rendezvousLoginFlow({ mcpUrl, clientId, code, log });
201
203
  const merged = mergeActivityBridge(prior, mcpUrl, creds);
202
204
  writeCredentials(path, merged);
203
205
  return merged;
204
206
  }
205
207
 
206
208
  /**
207
- * `loginFlow`/`deviceLoginFlow`/`redeemCodeFlow` all return the bare OAuth shape —
209
+ * `loginFlow`/`deviceLoginFlow`/`rendezvousLoginFlow` all return the bare OAuth shape —
208
210
  * none of them know about activityToken/activityUrl, a SEPARATE credential this
209
211
  * same file caches via cacheActivityBridge. A bare re-login (no --code) after a
210
212
  * prior --code sign-in would otherwise silently drop it on the next `writeCredentials`
@@ -258,7 +260,7 @@ export async function run(argv, _ctx) {
258
260
  if (args.code) {
259
261
  console.error(`~ signing in to Token of Trust with your invite code (${mcpUrl})`);
260
262
  try {
261
- await redeemAndCache(mcpUrl, args.code, env);
263
+ await redeemAndCache(mcpUrl, args.code, env, { log: (m) => console.error(m) });
262
264
  cacheActivityBridge(env, args.activityToken, args.activityUrl);
263
265
  cacheTraceId(env, args.traceId);
264
266
  cacheEmailHint(env, args.emailHint);
package/src/oauth.mjs CHANGED
@@ -285,41 +285,50 @@ export async function loginFlow({
285
285
  }
286
286
  }
287
287
 
288
- // ── WS2b: browserless invite-code redemption (`tot login --code <token>`) ─────
288
+ // ── Browserless CLI sign-in RFC 8628 RENDEZVOUS (`tot login --code <handle>`) ──
289
289
  //
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.
290
+ // The invited developer pastes the NON-SECRET rendezvous handle their cockpit
291
+ // rendered. Unlike the retired bearer-redeem (a secret received elsewhere, replayed
292
+ // once), the terminal generates its OWN PKCE key here, ATTACHES only the public
293
+ // challenge to the pending rendezvous, prints a human-verifiable fingerprint, and
294
+ // polls the standard token endpoint (device_code grant + PKCE) until the developer
295
+ // approves that fingerprint in the cockpit. Same credentials shape as
296
+ // loginFlow/deviceLoginFlow, same dynamically-registered client_id, so later silent
297
+ // refreshes go through the standard token endpoint like any other session.
296
298
  //
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)
299
+ // Wire contract (CLI → MCP), all off the MCP origin (from the AS token endpoint):
300
+ // POST /oauth/device/attach (application/json)
301
+ // { rendezvous_code, client_id, code_challenge, code_challenge_method: "S256" }
302
+ // → 200 { device_code, user_fingerprint, interval, expires_in }
303
+ // → 400 { error, error_description } (invalid_grant | invalid_client)
304
+ // POST /oauth/token (form) grant_type=…:device_code, device_code, code_verifier,
305
+ // client_id → RFC 8628 polling until approved/denied/expired.
302
306
  //
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) {
307
+ // Device-bound by construction: the pasted handle attaches only a PUBLIC PKCE
308
+ // challenge (intercepting it grants nothing an interceptor's terminal shows a
309
+ // DIFFERENT fingerprint the developer won't approve), and only the terminal holding
310
+ // the matching verifier can redeem the device_code at /oauth/token.
311
+
312
+ /** An MCP device/rendezvous endpoint — the MCP origin (from the AS token endpoint)
313
+ * + a fixed path. Kept beside the flow so the paths live in exactly one place. */
314
+ export function deviceEndpoint(mcpUrl, meta, path) {
312
315
  const origin = meta?.token_endpoint ? new URL(meta.token_endpoint) : new URL(mcpUrl);
313
- return new URL("/oauth/redeem-code", origin).toString();
316
+ return new URL(path, origin).toString();
314
317
  }
315
318
 
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, {
319
+ /** Attach the terminal's PKCE challenge to a pending rendezvous. Returns the raw
320
+ * response { device_code, user_fingerprint, interval, expires_in }. Throws a clear,
321
+ * non-stack error on rejection (an expired / already-used handle). */
322
+ export async function attachRendezvous(attachEndpoint, { rendezvousCode, clientId, challenge }, fetchImpl = fetch) {
323
+ const res = await fetchImpl(attachEndpoint, {
320
324
  method: "POST",
321
325
  headers: { "Content-Type": "application/json", Accept: "application/json" },
322
- body: JSON.stringify({ code, client_id: clientId, scope }),
326
+ body: JSON.stringify({
327
+ rendezvous_code: rendezvousCode,
328
+ client_id: clientId,
329
+ code_challenge: challenge,
330
+ code_challenge_method: "S256",
331
+ }),
323
332
  });
324
333
  const text = await res.text();
325
334
  let body;
@@ -328,31 +337,57 @@ export async function redeemInviteCode(redeemEndpoint, { code, clientId, scope =
328
337
  const detail = [body.error, body.error_description].filter(Boolean).join(" — ");
329
338
  throw new Error(`your sign-in code was not accepted (HTTP ${res.status}${detail ? `: ${detail}` : ""})`);
330
339
  }
331
- if (!body.access_token) throw new Error("the redeem endpoint returned no access_token");
340
+ if (!body.device_code || !body.user_fingerprint) {
341
+ throw new Error("the attach endpoint returned no device_code/fingerprint");
342
+ }
332
343
  return body;
333
344
  }
334
345
 
335
346
  /**
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.
347
+ * Run the full browserless rendezvous sign-in and return a persistable credentials
348
+ * record — the invite sibling of loginFlow()/deviceLoginFlow(), same credentials
349
+ * shape + dynamically-registered client_id (reused when the caller cached one for
350
+ * this MCP). The terminal generates its own PKCE key, attaches, surfaces the
351
+ * fingerprint via `log` for the developer to confirm in the cockpit, then polls the
352
+ * token endpoint until approved. Injectable (`fetchImpl`, `log`, `sleep`, `now`) so
353
+ * it's testable with no network and no real waiting.
340
354
  * @returns {Promise<object>} credentials to hand to writeCredentials()
341
355
  */
342
- export async function redeemCodeFlow({
356
+ export async function rendezvousLoginFlow({
343
357
  mcpUrl,
344
358
  clientId,
345
359
  code,
346
360
  fetchImpl = fetch,
361
+ log = () => {},
362
+ sleep = delay,
347
363
  now = () => Date.now(),
348
364
  }) {
349
365
  const meta = await discoverMetadata(mcpUrl, fetchImpl);
350
366
  const resolvedClientId = clientId || (await registerClient(meta.registration_endpoint, LOOPBACK_REDIRECT, fetchImpl));
351
- const token = await redeemInviteCode(
352
- redeemCodeEndpoint(mcpUrl, meta),
353
- { code, clientId: resolvedClientId },
367
+ const { verifier, challenge } = generatePkce();
368
+
369
+ const attach = await attachRendezvous(
370
+ deviceEndpoint(mcpUrl, meta, "/oauth/device/attach"),
371
+ { rendezvousCode: code, clientId: resolvedClientId, challenge },
354
372
  fetchImpl,
355
373
  );
374
+
375
+ log("");
376
+ log(` Confirm this code in your browser to finish signing in: ${attach.user_fingerprint}`);
377
+ log(" Waiting for you to approve it in Token of Trust …");
378
+
379
+ const token = await pollDeviceToken(
380
+ meta.token_endpoint,
381
+ {
382
+ deviceCode: attach.device_code,
383
+ clientId: resolvedClientId,
384
+ codeVerifier: verifier,
385
+ intervalSec: attach.interval,
386
+ expiresInSec: attach.expires_in,
387
+ },
388
+ fetchImpl,
389
+ { sleep, now },
390
+ );
356
391
  return credentialsFromToken({
357
392
  mcpUrl,
358
393
  clientId: resolvedClientId,
@@ -395,7 +430,7 @@ export async function deviceAuthorize(deviceAuthorizationEndpoint, { clientId, s
395
430
  * normal "keep waiting" responses — so this resolves `{ pending: true }`
396
431
  * (with `slowDown` set) for those instead of throwing.
397
432
  */
398
- async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId }, fetchImpl) {
433
+ async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId, codeVerifier }, fetchImpl) {
399
434
  const res = await fetchImpl(tokenEndpoint, {
400
435
  method: "POST",
401
436
  headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
@@ -403,6 +438,10 @@ async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId }, fetchImp
403
438
  grant_type: "urn:ietf:params:oauth:grant-type:device_code",
404
439
  device_code: deviceCode,
405
440
  client_id: clientId,
441
+ // The rendezvous flow binds the grant to the terminal's PKCE key: the
442
+ // verifier proves this is the same terminal that attached the challenge.
443
+ // Absent for the plain RFC 8628 device flow (no PKCE) — omitted then.
444
+ ...(codeVerifier ? { code_verifier: codeVerifier } : {}),
406
445
  }).toString(),
407
446
  });
408
447
  const text = await res.text();
@@ -428,7 +467,7 @@ async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId }, fetchImp
428
467
  */
429
468
  export async function pollDeviceToken(
430
469
  tokenEndpoint,
431
- { deviceCode, clientId, intervalSec, expiresInSec },
470
+ { deviceCode, clientId, codeVerifier, intervalSec, expiresInSec },
432
471
  fetchImpl = fetch,
433
472
  { sleep = delay, now = () => Date.now() } = {},
434
473
  ) {
@@ -437,7 +476,7 @@ export async function pollDeviceToken(
437
476
  for (;;) {
438
477
  await sleep(intervalMs);
439
478
  if (now() >= deadline) throw new Error("the device code expired before it was approved");
440
- const r = await deviceTokenPoll(tokenEndpoint, { deviceCode, clientId }, fetchImpl);
479
+ const r = await deviceTokenPoll(tokenEndpoint, { deviceCode, clientId, codeVerifier }, fetchImpl);
441
480
  if (!r.pending) return r.token;
442
481
  if (r.slowDown) intervalMs += 5000;
443
482
  }