@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 +1 -1
- package/src/commands/login.mjs +10 -8
- package/src/oauth.mjs +78 -39
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "1.4.0-rc.
|
|
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",
|
package/src/commands/login.mjs
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*
|
|
20
20
|
* Dependency-free (node built-ins via oauth.mjs).
|
|
21
21
|
*/
|
|
22
|
-
import { loginFlow, deviceLoginFlow,
|
|
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
|
|
192
|
-
*
|
|
193
|
-
*
|
|
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
|
|
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`/`
|
|
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
|
-
// ──
|
|
288
|
+
// ── Browserless CLI sign-in — RFC 8628 RENDEZVOUS (`tot login --code <handle>`) ──
|
|
289
289
|
//
|
|
290
|
-
// The invited developer pastes the
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
// the
|
|
294
|
-
//
|
|
295
|
-
//
|
|
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
|
|
299
|
-
// {
|
|
300
|
-
//
|
|
301
|
-
//
|
|
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
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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(
|
|
316
|
+
return new URL(path, origin).toString();
|
|
314
317
|
}
|
|
315
318
|
|
|
316
|
-
/**
|
|
317
|
-
* response
|
|
318
|
-
|
|
319
|
-
|
|
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({
|
|
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.
|
|
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
|
|
337
|
-
* — the invite
|
|
338
|
-
* shape
|
|
339
|
-
*
|
|
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
|
|
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
|
|
352
|
-
|
|
353
|
-
|
|
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
|
}
|