@tokenoftrust/cli 2.0.11 → 2.0.14

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
@@ -26,6 +26,7 @@ import http from "node:http";
26
26
  import crypto from "node:crypto";
27
27
  import { setTimeout as delay } from "node:timers/promises";
28
28
  import { openBrowser } from "./open.mjs";
29
+ import { CliDiagnosticError, sanitizeDiagnosticText } from "./diagnostics.mjs";
29
30
 
30
31
  const CLIENT_NAME = "Token of Trust CLI (tot)";
31
32
  const SCOPE = "mcp offline_access"; // offline_access → the refresh token
@@ -34,6 +35,87 @@ const SCOPE = "mcp offline_access"; // offline_access → the refresh token
34
35
  // ephemeral port that the MCP matches port-agnostically.
35
36
  const LOOPBACK_REDIRECT = "http://127.0.0.1/callback";
36
37
 
38
+ function oauthTraceId() {
39
+ return crypto.randomUUID().replaceAll("-", "");
40
+ }
41
+
42
+ async function oauthJson(url, init, operation, fetchImpl) {
43
+ const traceId = oauthTraceId();
44
+ let res;
45
+ try {
46
+ res = await fetchImpl(url, {
47
+ ...init,
48
+ headers: { ...(init?.headers || {}), "X-Trace-Id": traceId },
49
+ });
50
+ } catch (cause) {
51
+ throw new CliDiagnosticError(`${operation} failed before a response arrived`, {
52
+ operation, category: "network", message: cause?.message || String(cause), traceId,
53
+ }, { cause });
54
+ }
55
+ const returnedTraceId = res.headers?.get?.("x-trace-id") || traceId;
56
+ const requestId = res.headers?.get?.("x-request-id") || returnedTraceId;
57
+ const contentType = res.headers?.get?.("content-type") || "";
58
+ let text;
59
+ let body;
60
+ let parseError;
61
+ try {
62
+ if (typeof res.text === "function") {
63
+ text = await res.text();
64
+ body = text ? JSON.parse(text) : null;
65
+ } else {
66
+ body = await res.json();
67
+ text = JSON.stringify(body);
68
+ }
69
+ } catch (cause) {
70
+ parseError = cause;
71
+ body = null;
72
+ }
73
+ const responseBytes = text === undefined ? undefined : Buffer.byteLength(text);
74
+ if (res.ok && parseError) {
75
+ throw new CliDiagnosticError(`${operation} returned malformed JSON`, {
76
+ operation, category: "malformed_response", httpStatus: res.status,
77
+ traceId: returnedTraceId, requestId, contentType, responseBytes,
78
+ }, { cause: parseError });
79
+ }
80
+ if (res.ok && body === null) {
81
+ throw new CliDiagnosticError(`${operation} returned an empty response`, {
82
+ operation, category: "empty_response", httpStatus: res.status,
83
+ traceId: returnedTraceId, requestId, contentType, responseBytes,
84
+ });
85
+ }
86
+ body ||= {};
87
+ return {
88
+ res,
89
+ body,
90
+ context: {
91
+ operation,
92
+ httpStatus: res.status,
93
+ statusText: res.statusText,
94
+ serverCode: typeof body?.error === "string" ? body.error : undefined,
95
+ message: sanitizeDiagnosticText(body?.error_description || body?.error || "", 300),
96
+ traceId: returnedTraceId,
97
+ requestId,
98
+ contentType,
99
+ responseBytes: responseBytes ?? 0,
100
+ },
101
+ };
102
+ }
103
+
104
+ function oauthFailure(message, context) {
105
+ return new CliDiagnosticError(message, {
106
+ ...context,
107
+ category: context.serverCode ? "oauth" : `http_${context.httpStatus}`,
108
+ });
109
+ }
110
+
111
+ function oauthShapeFailure(message, context) {
112
+ return new CliDiagnosticError(message, {
113
+ ...context,
114
+ category: "malformed_response",
115
+ message,
116
+ });
117
+ }
118
+
37
119
  const b64url = (buf) =>
38
120
  buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
39
121
 
@@ -63,11 +145,12 @@ export function randomState() {
63
145
  export async function discoverMetadata(mcpUrl, fetchImpl = fetch) {
64
146
  const root = String(mcpUrl).replace(/\/+$/, "");
65
147
  const url = `${root}/.well-known/oauth-authorization-server`;
66
- const res = await fetchImpl(url, { headers: { Accept: "application/json" } });
67
- if (!res.ok) throw new Error(`could not read the MCP's OAuth metadata (HTTP ${res.status} at ${url})`);
68
- const meta = await res.json();
148
+ const { res, body: meta, context } = await oauthJson(
149
+ url, { headers: { Accept: "application/json" } }, "oauth_metadata", fetchImpl,
150
+ );
151
+ if (!res.ok) throw oauthFailure(`could not read the MCP's OAuth metadata (HTTP ${res.status} at ${url})`, context);
69
152
  if (!meta?.authorization_endpoint || !meta?.token_endpoint) {
70
- throw new Error("the MCP's OAuth metadata is missing authorization/token endpoints");
153
+ throw oauthShapeFailure("the MCP's OAuth metadata is missing authorization/token endpoints", context);
71
154
  }
72
155
  return meta;
73
156
  }
@@ -79,7 +162,7 @@ export async function registerClient(
79
162
  fetchImpl = fetch,
80
163
  ) {
81
164
  if (!registrationEndpoint) throw new Error("the MCP does not advertise a registration endpoint");
82
- const res = await fetchImpl(registrationEndpoint, {
165
+ const { res, body, context } = await oauthJson(registrationEndpoint, {
83
166
  method: "POST",
84
167
  headers: { "Content-Type": "application/json", Accept: "application/json" },
85
168
  body: JSON.stringify({
@@ -97,10 +180,9 @@ export async function registerClient(
97
180
  token_endpoint_auth_method: "none",
98
181
  scope: SCOPE,
99
182
  }),
100
- });
101
- if (!res.ok) throw new Error(`client registration failed (HTTP ${res.status})`);
102
- const body = await res.json();
103
- if (!body?.client_id) throw new Error("client registration returned no client_id");
183
+ }, "oauth_client_registration", fetchImpl);
184
+ if (!res.ok) throw oauthFailure(`client registration failed (HTTP ${res.status})`, context);
185
+ if (!body?.client_id) throw oauthShapeFailure("client registration returned no client_id", context);
104
186
  return body.client_id;
105
187
  }
106
188
 
@@ -121,22 +203,19 @@ export function buildAuthorizeUrl(authorizationEndpoint, {
121
203
  }
122
204
 
123
205
  async function tokenRequest(tokenEndpoint, params, fetchImpl) {
124
- const res = await fetchImpl(tokenEndpoint, {
206
+ const { res, body, context } = await oauthJson(tokenEndpoint, {
125
207
  method: "POST",
126
208
  headers: {
127
209
  "Content-Type": "application/x-www-form-urlencoded",
128
210
  Accept: "application/json",
129
211
  },
130
212
  body: new URLSearchParams(params).toString(),
131
- });
132
- const text = await res.text();
133
- let body;
134
- try { body = text ? JSON.parse(text) : {}; } catch { body = {}; }
213
+ }, "oauth_token", fetchImpl);
135
214
  if (!res.ok) {
136
215
  const detail = [body.error, body.error_description].filter(Boolean).join(" — ");
137
- throw new Error(`token request failed (HTTP ${res.status}${detail ? `: ${detail}` : ""})`);
216
+ throw oauthFailure(`token request failed (HTTP ${res.status}${detail ? `: ${detail}` : ""})`, context);
138
217
  }
139
- if (!body.access_token) throw new Error("token endpoint returned no access_token");
218
+ if (!body.access_token) throw oauthShapeFailure("token endpoint returned no access_token", context);
140
219
  return body;
141
220
  }
142
221
 
@@ -327,7 +406,7 @@ export function deviceEndpoint(mcpUrl, meta, path) {
327
406
  * response { device_code, user_fingerprint, interval, expires_in }. Throws a clear,
328
407
  * non-stack error on rejection (an expired / already-used handle). */
329
408
  export async function attachRendezvous(attachEndpoint, { rendezvousCode, clientId, challenge }, fetchImpl = fetch) {
330
- const res = await fetchImpl(attachEndpoint, {
409
+ const { res, body, context } = await oauthJson(attachEndpoint, {
331
410
  method: "POST",
332
411
  headers: { "Content-Type": "application/json", Accept: "application/json" },
333
412
  body: JSON.stringify({
@@ -336,16 +415,13 @@ export async function attachRendezvous(attachEndpoint, { rendezvousCode, clientI
336
415
  code_challenge: challenge,
337
416
  code_challenge_method: "S256",
338
417
  }),
339
- });
340
- const text = await res.text();
341
- let body;
342
- try { body = text ? JSON.parse(text) : {}; } catch { body = {}; }
418
+ }, "oauth_rendezvous_attach", fetchImpl);
343
419
  if (!res.ok) {
344
420
  const detail = [body.error, body.error_description].filter(Boolean).join(" — ");
345
- throw new Error(`your sign-in code was not accepted (HTTP ${res.status}${detail ? `: ${detail}` : ""})`);
421
+ throw oauthFailure(`your sign-in code was not accepted (HTTP ${res.status}${detail ? `: ${detail}` : ""})`, context);
346
422
  }
347
423
  if (!body.device_code || !body.user_fingerprint) {
348
- throw new Error("the attach endpoint returned no device_code/fingerprint");
424
+ throw oauthShapeFailure("the attach endpoint returned no device_code/fingerprint", context);
349
425
  }
350
426
  return body;
351
427
  }
@@ -417,20 +493,17 @@ export async function rendezvousLoginFlow({
417
493
  * verification_uri_complete?, expires_in, interval}>}
418
494
  */
419
495
  export async function deviceAuthorize(deviceAuthorizationEndpoint, { clientId, scope = SCOPE }, fetchImpl = fetch) {
420
- const res = await fetchImpl(deviceAuthorizationEndpoint, {
496
+ const { res, body, context } = await oauthJson(deviceAuthorizationEndpoint, {
421
497
  method: "POST",
422
498
  headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
423
499
  body: new URLSearchParams({ client_id: clientId, scope }).toString(),
424
- });
425
- const text = await res.text();
426
- let body;
427
- try { body = text ? JSON.parse(text) : {}; } catch { body = {}; }
500
+ }, "oauth_device_authorization", fetchImpl);
428
501
  if (!res.ok) {
429
502
  const detail = [body.error, body.error_description].filter(Boolean).join(" — ");
430
- throw new Error(`device authorization request failed (HTTP ${res.status}${detail ? `: ${detail}` : ""})`);
503
+ throw oauthFailure(`device authorization request failed (HTTP ${res.status}${detail ? `: ${detail}` : ""})`, context);
431
504
  }
432
505
  if (!body.device_code || !body.user_code) {
433
- throw new Error("device authorization response is missing device_code/user_code");
506
+ throw oauthShapeFailure("device authorization response is missing device_code/user_code", context);
434
507
  }
435
508
  return body;
436
509
  }
@@ -442,7 +515,7 @@ export async function deviceAuthorize(deviceAuthorizationEndpoint, { clientId, s
442
515
  * (with `slowDown` set) for those instead of throwing.
443
516
  */
444
517
  async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId, codeVerifier }, fetchImpl) {
445
- const res = await fetchImpl(tokenEndpoint, {
518
+ const { res, body, context } = await oauthJson(tokenEndpoint, {
446
519
  method: "POST",
447
520
  headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
448
521
  body: new URLSearchParams({
@@ -454,19 +527,16 @@ async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId, codeVerifi
454
527
  // Absent for the plain RFC 8628 device flow (no PKCE) — omitted then.
455
528
  ...(codeVerifier ? { code_verifier: codeVerifier } : {}),
456
529
  }).toString(),
457
- });
458
- const text = await res.text();
459
- let body;
460
- try { body = text ? JSON.parse(text) : {}; } catch { body = {}; }
530
+ }, "oauth_device_token", fetchImpl);
461
531
  if (res.ok) {
462
- if (!body.access_token) throw new Error("token endpoint returned no access_token");
532
+ if (!body.access_token) throw oauthShapeFailure("token endpoint returned no access_token", context);
463
533
  return { pending: false, token: body };
464
534
  }
465
535
  if (body.error === "authorization_pending") return { pending: true, slowDown: false };
466
536
  if (body.error === "slow_down") return { pending: true, slowDown: true };
467
537
  const reason = body.error === "access_denied" ? "was denied" : body.error === "expired_token" ? "code expired" : "failed";
468
538
  const detail = body.error_description ? ` — ${body.error_description}` : "";
469
- throw new Error(`sign-in ${reason}${detail}`);
539
+ throw oauthFailure(`sign-in ${reason}${detail}`, context);
470
540
  }
471
541
 
472
542
  /**
package/src/open.mjs CHANGED
@@ -17,19 +17,34 @@ import { setTimeout as delay } from "node:timers/promises";
17
17
  * Mirror of scripts/dev/port-check.mjs (separate package — kept dependency-free).
18
18
  * @param {number} port @returns {Promise<boolean>}
19
19
  */
20
- export function isPortFree(port) {
20
+ /**
21
+ * @param {number} port
22
+ * @param {string | undefined} [host]
23
+ * @param {boolean} [unsupportedIsFree]
24
+ */
25
+ function canBind(port, host = undefined, unsupportedIsFree = false) {
21
26
  return new Promise((resolve) => {
22
27
  const srv = net.createServer();
23
- srv.once("error", () => resolve(false));
28
+ srv.once("error", (error) => {
29
+ const code = /** @type {NodeJS.ErrnoException} */ (error).code;
30
+ const unsupported = code === "EAFNOSUPPORT" || code === "EADDRNOTAVAIL";
31
+ resolve(unsupportedIsFree && unsupported);
32
+ });
24
33
  srv.once("listening", () => srv.close(() => resolve(true)));
25
34
  try {
26
- srv.listen(port);
35
+ srv.listen({ port, ...(host ? { host } : {}) });
27
36
  } catch {
28
37
  resolve(false);
29
38
  }
30
39
  });
31
40
  }
32
41
 
42
+ export async function isPortFree(port) {
43
+ if (!(await canBind(port))) return false;
44
+ if (!(await canBind(port, "127.0.0.1"))) return false;
45
+ return canBind(port, "::1", true);
46
+ }
47
+
33
48
  /**
34
49
  * First free port at or after `preferred`. Lets the CLI derive the URL it opens +
35
50
  * polls from the SAME port the runner will bind — Vite's strictPort is off, so a