@tokenoftrust/cli 1.4.0-rc.0 → 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/README.md +13 -7
- package/package.json +1 -1
- package/src/commands/login.mjs +10 -8
- package/src/oauth.mjs +78 -39
- package/src/token-store.mjs +38 -15
package/README.md
CHANGED
|
@@ -46,18 +46,24 @@ The same `tot` does the right thing wherever you run it (walks up like `git`):
|
|
|
46
46
|
- Not signed in? On a terminal, `tot start` / `tot checkout` **offer to sign you in right there** and continue in-flow — no "run `tot login`, then re-run".
|
|
47
47
|
- The old operator env-triple (`TOT_API_KEY` / `TOT_SECRET_KEY` / `TOT_APP_DOMAIN`) **no longer signs the CLI in** — tot-mcp went OAuth-first on 2026-07-23. If those vars are set, `tot` prints a one-line advisory and uses your `tot login` session anyway; it never reads them for auth.
|
|
48
48
|
|
|
49
|
-
###
|
|
49
|
+
### Optional: multiple identities at once (`TOT_PROFILE`)
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
**Most people never set this** — the single default session is all you need, and a plain `tot login` just replaces it. Reach for a profile only when you want **more than one identity live at the same time**: a staff `@tokenoftrust.com` sign-in alongside a plain developer one, or many parallel test identities. Set it per shell and each gets its own credential file under `~/.tot` (the renderer cache and everything else stay shared):
|
|
52
52
|
|
|
53
53
|
```
|
|
54
|
-
# terminal A
|
|
55
|
-
export TOT_PROFILE=staff
|
|
56
|
-
|
|
57
|
-
export TOT_PROFILE=dev && tot login
|
|
54
|
+
# terminal A # terminal B
|
|
55
|
+
export TOT_PROFILE=staff export TOT_PROFILE=dev
|
|
56
|
+
tot login tot login
|
|
58
57
|
```
|
|
59
58
|
|
|
60
|
-
|
|
59
|
+
The value is an **opaque label** — any string works, so it's easy to script parallel identities:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
export TOT_PROFILE=$(uuidgen) # a fresh isolated identity per terminal / test worker
|
|
63
|
+
tot login
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
A clean short name (`staff`, `dev`, `test-7`) becomes a readable `credentials.<name>.json`; any other value (symbols, uppercase, long) is hashed to a stable `credentials.h<hash>.json` — so an arbitrary id never collides or escapes `~/.tot`. Unset → the default session, unchanged. `tot whoami` shows the active profile.
|
|
61
67
|
|
|
62
68
|
## Design notes
|
|
63
69
|
|
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
|
}
|
package/src/token-store.mjs
CHANGED
|
@@ -30,36 +30,59 @@
|
|
|
30
30
|
*
|
|
31
31
|
* `TOT_HOME` overrides the home dir (used by tests to point at a temp dir).
|
|
32
32
|
*
|
|
33
|
-
* PROFILES (`TOT_PROFILE`)
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* (the renderer cache, last-tenant, etc.
|
|
39
|
-
*
|
|
33
|
+
* PROFILES (`TOT_PROFILE`) — OPTIONAL, for parallel work. Almost every developer
|
|
34
|
+
* leaves this UNSET and uses the single default `credentials.json` — that path is
|
|
35
|
+
* unchanged and is the norm. It exists only when you want MORE THAN ONE identity
|
|
36
|
+
* live at once (a staff `@tokenoftrust.com` sign-in and a plain developer one, or
|
|
37
|
+
* many parallel test identities): export a profile per shell and each gets its OWN
|
|
38
|
+
* credential file under the same `~/.tot` (the renderer cache, last-tenant, etc.
|
|
39
|
+
* stay shared — only the identity splits). The value is an OPAQUE label — any
|
|
40
|
+
* string works, so `export TOT_PROFILE=$(uuidgen)` per terminal/test is fine:
|
|
41
|
+
* - a clean short token (`staff`, `dev`, `test-7`) is used verbatim for a
|
|
42
|
+
* readable `credentials.<profile>.json`;
|
|
43
|
+
* - any other value (symbols, uppercase, long) is HASHED to a stable, safe
|
|
44
|
+
* `credentials.h<hash>.json` — so an arbitrary opaque id can never collide
|
|
45
|
+
* with another or escape `~/.tot`, while `tot whoami` still shows what you set.
|
|
40
46
|
*/
|
|
41
47
|
import {
|
|
42
48
|
readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync, existsSync, rmSync,
|
|
43
49
|
} from "node:fs";
|
|
44
50
|
import { homedir } from "node:os";
|
|
51
|
+
import { createHash } from "node:crypto";
|
|
45
52
|
import { join, dirname } from "node:path";
|
|
46
53
|
|
|
54
|
+
/** A short token safe to drop straight into a filename (readable profiles). */
|
|
55
|
+
const CLEAN_PROFILE_RE = /^[a-z0-9_-]{1,64}$/;
|
|
56
|
+
|
|
47
57
|
/**
|
|
48
|
-
* The active
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* stray `/` or `..` from ever escaping the `~/.tot` dir.
|
|
58
|
+
* The active profile's DISPLAY label — the raw `TOT_PROFILE`, trimmed — or null
|
|
59
|
+
* when unset. This is what `tot whoami` shows; it is NOT the filename (see
|
|
60
|
+
* profileSlug, which makes any value filesystem-safe).
|
|
52
61
|
*/
|
|
53
62
|
export function activeProfile(env = process.env) {
|
|
54
|
-
|
|
55
|
-
|
|
63
|
+
return String(env.TOT_PROFILE || "").trim() || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The active profile's safe filename token, or null for the default session. A
|
|
68
|
+
* clean short label passes through verbatim (readable files); ANY other opaque
|
|
69
|
+
* value is hashed — collision-resistant and incapable of a `/` or `..` path
|
|
70
|
+
* escape, so `TOT_PROFILE` can be a literally arbitrary identifier. Hashing the
|
|
71
|
+
* RAW value (not a stripped form) keeps distinct tokens distinct.
|
|
72
|
+
*/
|
|
73
|
+
export function profileSlug(env = process.env) {
|
|
74
|
+
const raw = String(env.TOT_PROFILE || "").trim();
|
|
75
|
+
if (!raw) return null;
|
|
76
|
+
const lower = raw.toLowerCase();
|
|
77
|
+
if (CLEAN_PROFILE_RE.test(lower)) return lower;
|
|
78
|
+
return `h${createHash("sha256").update(raw).digest("hex").slice(0, 16)}`;
|
|
56
79
|
}
|
|
57
80
|
|
|
58
81
|
/** Absolute path to the credential file for this environment (+ TOT_PROFILE). */
|
|
59
82
|
export function defaultCredentialsPath(env = process.env) {
|
|
60
83
|
const home = env.TOT_HOME || homedir();
|
|
61
|
-
const
|
|
62
|
-
return join(home, ".tot",
|
|
84
|
+
const slug = profileSlug(env);
|
|
85
|
+
return join(home, ".tot", slug ? `credentials.${slug}.json` : "credentials.json");
|
|
63
86
|
}
|
|
64
87
|
|
|
65
88
|
/**
|