@anyslate/cli 0.2.0 → 0.3.1

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 ADDED
@@ -0,0 +1,633 @@
1
+ // OAuth 2.1 authorization-code + PKCE + Dynamic Client Registration for
2
+ // `anyslate login`.
3
+ //
4
+ // WHY THIS EXISTS. `anyslate login --token as_mcp_…` required the user to open
5
+ // the desktop app, mint a token, and paste it into a terminal. That is four
6
+ // context switches for a first-run experience, and the pasted token never
7
+ // expires, so it is also the worst credential to leave lying in a shell
8
+ // history. The browser flow replaces it for humans; `--token` stays for CI.
9
+ //
10
+ // EVERY ENDPOINT IS DISCOVERED, NOTHING IS GUESSED.
11
+ // GET {root}/.well-known/oauth-authorization-server → issuer,
12
+ // authorization_endpoint, token_endpoint, registration_endpoint,
13
+ // code_challenge_methods_supported, grant_types_supported
14
+ // GET {root}/.well-known/oauth-protected-resource → resource
15
+ //
16
+ // The `resource` value is REQUIRED on /oauth/authorize and accepted on
17
+ // /oauth/token. It is read from the protected-resource document for the root
18
+ // being logged into — never hardcoded — because dev, prod and a local wrangler
19
+ // instance each publish a different one, and a wrong `resource` is rejected at
20
+ // the authorize step with no useful diagnostic.
21
+ //
22
+ // LOOPBACK PORTS AND THE REGISTERED REDIRECT URI. RFC 8252 §7.3 says a native
23
+ // app must be able to bind an ephemeral loopback port, so the server's
24
+ // redirect-URI comparison ignores the port for loopback URIs (it compares
25
+ // protocol + hostname + pathname). We therefore register ONE canonical
26
+ // `http://127.0.0.1/callback` and send the actual `http://127.0.0.1:<port>/callback`
27
+ // at authorize/exchange time. Registering the live port instead would burn a
28
+ // Dynamic Client Registration on every login — and DCR is rate limited to 10
29
+ // per hour, so that would lock a user out after ten logins.
30
+
31
+ import { createHash, randomBytes } from 'node:crypto';
32
+ import { createServer } from 'node:http';
33
+ import { spawn } from 'node:child_process';
34
+ import { USER_AGENT } from './version.mjs';
35
+ import { classifyNetworkError } from './mcp-client.mjs';
36
+
37
+ export const AS_METADATA_PATH = '/.well-known/oauth-authorization-server';
38
+ export const RESOURCE_METADATA_PATH = '/.well-known/oauth-protected-resource';
39
+
40
+ export const LOOPBACK_HOST = '127.0.0.1';
41
+ export const CALLBACK_PATH = '/callback';
42
+
43
+ /** The URI we register once and reuse forever. The port is deliberately absent. */
44
+ export const REGISTERED_REDIRECT_URI = `http://${LOOPBACK_HOST}${CALLBACK_PATH}`;
45
+
46
+ export const CLIENT_NAME = 'AnySlate CLI';
47
+
48
+ /** How long `login` waits on the loopback listener, in seconds. */
49
+ export const DEFAULT_CALLBACK_TIMEOUT_S = 180;
50
+
51
+ /** Access tokens live 3600s. Refresh this far ahead of expiry. */
52
+ export const REFRESH_SKEW_MS = 5 * 60 * 1000;
53
+
54
+ /** Fallback when the token response omits `expires_in` (server sends 3600). */
55
+ export const DEFAULT_EXPIRES_IN_S = 3600;
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // PKCE
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /** @param {Buffer|Uint8Array|string} input */
62
+ export function base64url(input) {
63
+ return Buffer.from(input)
64
+ .toString('base64')
65
+ .replace(/\+/g, '-')
66
+ .replace(/\//g, '_')
67
+ .replace(/=+$/, '');
68
+ }
69
+
70
+ /**
71
+ * S256: challenge = base64url(sha256(ASCII(verifier))).
72
+ *
73
+ * The hash is taken over the verifier's ASCII characters, NOT over the random
74
+ * bytes it was derived from. Hashing the raw bytes produces a challenge the
75
+ * server can never reproduce, and the failure surfaces only at the token
76
+ * endpoint as an opaque `invalid_grant`.
77
+ *
78
+ * @param {string} verifier
79
+ * @returns {string}
80
+ */
81
+ export function deriveCodeChallenge(verifier) {
82
+ return base64url(createHash('sha256').update(verifier, 'ascii').digest());
83
+ }
84
+
85
+ /**
86
+ * 64 random bytes → 86 base64url chars, inside RFC 7636's 43–128 range.
87
+ * @param {number} [bytes]
88
+ * @returns {{verifier: string, challenge: string, method: 'S256'}}
89
+ */
90
+ export function generatePkce(bytes = 64) {
91
+ const verifier = base64url(randomBytes(bytes));
92
+ return { verifier, challenge: deriveCodeChallenge(verifier), method: 'S256' };
93
+ }
94
+
95
+ export function generateState() {
96
+ return base64url(randomBytes(32));
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // HTTP helpers
101
+ // ---------------------------------------------------------------------------
102
+
103
+ function hostOf(url) {
104
+ try {
105
+ return new URL(url).host;
106
+ } catch {
107
+ return String(url);
108
+ }
109
+ }
110
+
111
+ async function requestJson(url, { method = 'GET', body, headers = {}, fetchImpl = fetch, timeoutMs = 15_000 }) {
112
+ const ac = new AbortController();
113
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
114
+ let res;
115
+ try {
116
+ res = await fetchImpl(url, {
117
+ method,
118
+ headers: { accept: 'application/json', 'user-agent': USER_AGENT, ...headers },
119
+ body,
120
+ signal: ac.signal,
121
+ });
122
+ } catch (e) {
123
+ return { ok: false, networkError: true, detail: classifyNetworkError(e, hostOf(url), timeoutMs) };
124
+ } finally {
125
+ clearTimeout(timer);
126
+ }
127
+
128
+ let parsed = null;
129
+ let text = '';
130
+ try {
131
+ text = await res.text();
132
+ parsed = text ? JSON.parse(text) : null;
133
+ } catch {
134
+ parsed = null;
135
+ }
136
+ return { ok: res.ok, status: res.status, body: parsed, text, res };
137
+ }
138
+
139
+ /** `{error, error_description}` rendered for a human, without losing either half. */
140
+ export function oauthErrorText(body, fallback) {
141
+ if (body && typeof body === 'object') {
142
+ const err = typeof body.error === 'string' ? body.error : null;
143
+ const desc = typeof body.error_description === 'string' ? body.error_description : null;
144
+ if (err && desc) return `${err}: ${desc}`;
145
+ if (err) return err;
146
+ if (desc) return desc;
147
+ }
148
+ return fallback;
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Discovery
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /**
156
+ * Fetch BOTH well-known documents. Either one missing is a hard failure —
157
+ * falling back to guessed paths (`/oauth/authorize`, `/oauth/token`) would turn
158
+ * "this host does not speak OAuth" into a confusing 404 three steps later.
159
+ *
160
+ * @param {{root: string, fetchImpl?: typeof fetch, timeoutMs?: number}} opts
161
+ * @returns {Promise<{ok: true, issuer: string|null, authorizationEndpoint: string,
162
+ * tokenEndpoint: string, registrationEndpoint: string, revocationEndpoint: string|null,
163
+ * resource: string, codeChallengeMethods: string[], grantTypes: string[], root: string}
164
+ * | {ok: false, code: string, message: string}>}
165
+ */
166
+ export async function discover({ root, fetchImpl = fetch, timeoutMs = 15_000 }) {
167
+ const host = hostOf(root);
168
+
169
+ const meta = await requestJson(`${root}${AS_METADATA_PATH}`, { fetchImpl, timeoutMs });
170
+ if (meta.networkError) {
171
+ return {
172
+ ok: false,
173
+ code: 'discovery_unreachable',
174
+ message: `anyslate: cannot reach ${host} for OAuth discovery — ${meta.detail}. Check --api-url and your network.`,
175
+ };
176
+ }
177
+ if (!meta.ok || !meta.body || typeof meta.body !== 'object') {
178
+ return {
179
+ ok: false,
180
+ code: 'discovery_failed',
181
+ message:
182
+ `anyslate: OAuth discovery failed at ${host} — GET ${AS_METADATA_PATH} returned HTTP ${meta.status}. ` +
183
+ `That host does not advertise an OAuth authorization server. Check --api-url, or sign in with \`anyslate login --token <BEARER>\`.`,
184
+ };
185
+ }
186
+
187
+ const authorizationEndpoint = str(meta.body.authorization_endpoint);
188
+ const tokenEndpoint = str(meta.body.token_endpoint);
189
+ const registrationEndpoint = str(meta.body.registration_endpoint);
190
+ const missing = [
191
+ !authorizationEndpoint && 'authorization_endpoint',
192
+ !tokenEndpoint && 'token_endpoint',
193
+ !registrationEndpoint && 'registration_endpoint',
194
+ ].filter(Boolean);
195
+ if (missing.length) {
196
+ return {
197
+ ok: false,
198
+ code: 'discovery_incomplete',
199
+ message: `anyslate: OAuth discovery at ${host} is missing ${missing.join(', ')}. The CLI will not guess endpoint paths.`,
200
+ };
201
+ }
202
+
203
+ const codeChallengeMethods = Array.isArray(meta.body.code_challenge_methods_supported)
204
+ ? meta.body.code_challenge_methods_supported.map(String)
205
+ : [];
206
+ if (codeChallengeMethods.length && !codeChallengeMethods.includes('S256')) {
207
+ return {
208
+ ok: false,
209
+ code: 'pkce_unsupported',
210
+ message: `anyslate: ${host} does not advertise PKCE S256 (got [${codeChallengeMethods.join(',')}]). The CLI will not fall back to a weaker method.`,
211
+ };
212
+ }
213
+
214
+ const resourceDoc = await requestJson(`${root}${RESOURCE_METADATA_PATH}`, { fetchImpl, timeoutMs });
215
+ if (resourceDoc.networkError) {
216
+ return {
217
+ ok: false,
218
+ code: 'discovery_unreachable',
219
+ message: `anyslate: cannot reach ${host} for OAuth discovery — ${resourceDoc.detail}. Check --api-url and your network.`,
220
+ };
221
+ }
222
+ const resource = str(resourceDoc.body?.resource);
223
+ if (!resourceDoc.ok || !resource) {
224
+ return {
225
+ ok: false,
226
+ code: 'resource_missing',
227
+ message:
228
+ `anyslate: OAuth discovery failed at ${host} — GET ${RESOURCE_METADATA_PATH} returned HTTP ${resourceDoc.status} with no \`resource\`. ` +
229
+ `That value is required on /oauth/authorize and the CLI reads it from this document rather than assuming one.`,
230
+ };
231
+ }
232
+
233
+ return {
234
+ ok: true,
235
+ root,
236
+ issuer: str(meta.body.issuer) || null,
237
+ authorizationEndpoint,
238
+ tokenEndpoint,
239
+ registrationEndpoint,
240
+ revocationEndpoint: str(meta.body.revocation_endpoint) || null,
241
+ resource,
242
+ codeChallengeMethods,
243
+ grantTypes: Array.isArray(meta.body.grant_types_supported) ? meta.body.grant_types_supported.map(String) : [],
244
+ };
245
+ }
246
+
247
+ function str(v) {
248
+ return typeof v === 'string' && v.trim() ? v.trim() : '';
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Dynamic Client Registration
253
+ // ---------------------------------------------------------------------------
254
+
255
+ /**
256
+ * DCR is rate limited to 10/hour. The caller MUST cache the returned client_id
257
+ * per root and reuse it; this function does no caching of its own.
258
+ *
259
+ * @param {{registrationEndpoint: string, redirectUri?: string, clientName?: string,
260
+ * fetchImpl?: typeof fetch, timeoutMs?: number}} opts
261
+ * @returns {Promise<{ok: true, clientId: string, raw: unknown} | {ok: false, code: string, message: string}>}
262
+ */
263
+ export async function registerClient({
264
+ registrationEndpoint,
265
+ redirectUri = REGISTERED_REDIRECT_URI,
266
+ clientName = CLIENT_NAME,
267
+ fetchImpl = fetch,
268
+ timeoutMs = 15_000,
269
+ }) {
270
+ const host = hostOf(registrationEndpoint);
271
+ const res = await requestJson(registrationEndpoint, {
272
+ method: 'POST',
273
+ headers: { 'content-type': 'application/json' },
274
+ body: JSON.stringify({
275
+ client_name: clientName,
276
+ redirect_uris: [redirectUri],
277
+ grant_types: ['authorization_code', 'refresh_token'],
278
+ }),
279
+ fetchImpl,
280
+ timeoutMs,
281
+ });
282
+
283
+ if (res.networkError) {
284
+ return { ok: false, code: 'unreachable', message: `anyslate: cannot reach ${host} to register the CLI — ${res.detail}.` };
285
+ }
286
+ if (res.status === 429) {
287
+ return {
288
+ ok: false,
289
+ code: 'registration_rate_limited',
290
+ message:
291
+ `anyslate: ${host} rate limited client registration (10 per hour). ` +
292
+ `Wait an hour, or sign in with \`anyslate login --token <BEARER>\`.`,
293
+ };
294
+ }
295
+ const clientId = str(res.body?.client_id);
296
+ if (!res.ok || !clientId) {
297
+ return {
298
+ ok: false,
299
+ code: 'registration_failed',
300
+ message: `anyslate: client registration at ${host} failed (HTTP ${res.status}) — ${oauthErrorText(res.body, 'no client_id in the response')}.`,
301
+ };
302
+ }
303
+ return { ok: true, clientId, raw: res.body };
304
+ }
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // Authorize
308
+ // ---------------------------------------------------------------------------
309
+
310
+ /**
311
+ * @param {{authorizationEndpoint: string, clientId: string, redirectUri: string,
312
+ * codeChallenge: string, state: string, resource: string, scope?: string}} opts
313
+ * @returns {string}
314
+ */
315
+ export function buildAuthorizeUrl({ authorizationEndpoint, clientId, redirectUri, codeChallenge, state, resource, scope }) {
316
+ const url = new URL(authorizationEndpoint);
317
+ url.searchParams.set('response_type', 'code');
318
+ url.searchParams.set('client_id', clientId);
319
+ url.searchParams.set('redirect_uri', redirectUri);
320
+ url.searchParams.set('code_challenge', codeChallenge);
321
+ url.searchParams.set('code_challenge_method', 'S256');
322
+ url.searchParams.set('state', state);
323
+ url.searchParams.set('resource', resource);
324
+ if (scope) url.searchParams.set('scope', scope);
325
+ return url.toString();
326
+ }
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // Loopback listener
330
+ // ---------------------------------------------------------------------------
331
+
332
+ function callbackPage({ title, body, tone }) {
333
+ const accent = tone === 'error' ? '#c0392b' : '#2f7d4f';
334
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8">
335
+ <meta name="viewport" content="width=device-width,initial-scale=1">
336
+ <title>${title} · AnySlate</title>
337
+ <style>
338
+ :root{color-scheme:light dark}
339
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
340
+ font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
341
+ background:#f6f7f9;color:#1c1f23}
342
+ @media (prefers-color-scheme:dark){body{background:#101214;color:#e6e8ea}
343
+ .card{background:#191c1f!important;box-shadow:none!important;border:1px solid #2a2f34}}
344
+ .card{background:#fff;border-radius:14px;padding:40px 44px;max-width:26rem;text-align:center;
345
+ box-shadow:0 1px 3px rgba(0,0,0,.08),0 8px 28px rgba(0,0,0,.06)}
346
+ h1{margin:0 0 .5rem;font-size:1.25rem;color:${accent}}
347
+ p{margin:0;opacity:.8;font-size:.95rem}
348
+ </style></head><body><div class="card"><h1>${title}</h1><p>${body}</p></div></body></html>`;
349
+ }
350
+
351
+ /**
352
+ * Bind an ephemeral loopback port and wait for exactly one authorization
353
+ * callback.
354
+ *
355
+ * @param {{state: string, timeoutMs?: number, path?: string, host?: string}} opts
356
+ * @returns {Promise<{port: number, redirectUri: string,
357
+ * waitForResult: () => Promise<{ok: true, code: string} | {ok: false, code: string, message: string}>,
358
+ * close: () => Promise<void>}>}
359
+ */
360
+ export async function startCallbackServer({
361
+ state,
362
+ timeoutMs = DEFAULT_CALLBACK_TIMEOUT_S * 1000,
363
+ path = CALLBACK_PATH,
364
+ host = LOOPBACK_HOST,
365
+ }) {
366
+ /** @type {(r: object) => void} */
367
+ let settle = () => {};
368
+ let settled = false;
369
+ const result = new Promise((resolve) => {
370
+ settle = (r) => {
371
+ if (settled) return;
372
+ settled = true;
373
+ resolve(r);
374
+ };
375
+ });
376
+
377
+ const server = createServer((req, res) => {
378
+ let url;
379
+ try {
380
+ url = new URL(req.url, `http://${host}`);
381
+ } catch {
382
+ res.writeHead(400).end();
383
+ return;
384
+ }
385
+
386
+ if (url.pathname === '/favicon.ico') {
387
+ res.writeHead(204).end();
388
+ return;
389
+ }
390
+ if (url.pathname !== path) {
391
+ res.writeHead(404, { 'content-type': 'text/plain' }).end('not found');
392
+ return;
393
+ }
394
+
395
+ const send = (status, page) => {
396
+ res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
397
+ res.end(page);
398
+ };
399
+
400
+ const err = url.searchParams.get('error');
401
+ if (err) {
402
+ const desc = url.searchParams.get('error_description') || '';
403
+ send(400, callbackPage({ title: 'Sign-in failed', body: 'You can close this tab and return to your terminal.', tone: 'error' }));
404
+ settle({
405
+ ok: false,
406
+ code: 'authorize_error',
407
+ message: `anyslate: the authorization server returned "${err}"${desc ? ` — ${desc}` : ''}.`,
408
+ });
409
+ return;
410
+ }
411
+
412
+ // State is checked BEFORE the code is touched. A callback whose state does
413
+ // not match ours is either a stale tab from a previous attempt or a
414
+ // cross-site request; either way the `code` in it is not ours to redeem.
415
+ const got = url.searchParams.get('state') || '';
416
+ if (got !== state) {
417
+ send(400, callbackPage({ title: 'Sign-in failed', body: 'This sign-in link did not match the one this terminal started.', tone: 'error' }));
418
+ settle({
419
+ ok: false,
420
+ code: 'state_mismatch',
421
+ message: 'anyslate: the callback `state` did not match the one this login started with — discarding it. Run `anyslate login` again.',
422
+ });
423
+ return;
424
+ }
425
+
426
+ const code = url.searchParams.get('code') || '';
427
+ if (!code) {
428
+ send(400, callbackPage({ title: 'Sign-in failed', body: 'The callback carried no authorization code.', tone: 'error' }));
429
+ settle({ ok: false, code: 'no_code', message: 'anyslate: the callback carried no authorization code.' });
430
+ return;
431
+ }
432
+
433
+ send(200, callbackPage({ title: 'Signed in to AnySlate', body: 'You can close this tab and return to your terminal.', tone: 'ok' }));
434
+ settle({ ok: true, code });
435
+ });
436
+
437
+ await new Promise((resolve, reject) => {
438
+ server.once('error', reject);
439
+ server.listen(0, host, resolve);
440
+ });
441
+ const { port } = server.address();
442
+
443
+ const timer = setTimeout(() => {
444
+ settle({
445
+ ok: false,
446
+ code: 'timeout',
447
+ message: `anyslate: timed out after ${Math.round(timeoutMs / 1000)}s waiting for the browser callback. Re-run \`anyslate login\`, or use --timeout <seconds>.`,
448
+ });
449
+ }, timeoutMs);
450
+ if (typeof timer.unref === 'function') timer.unref();
451
+
452
+ const close = () =>
453
+ new Promise((resolve) => {
454
+ clearTimeout(timer);
455
+ server.close(() => resolve());
456
+ server.closeAllConnections?.();
457
+ });
458
+
459
+ return {
460
+ port,
461
+ redirectUri: `http://${host}:${port}${path}`,
462
+ waitForResult: () => result,
463
+ close,
464
+ };
465
+ }
466
+
467
+ // ---------------------------------------------------------------------------
468
+ // Token endpoint
469
+ // ---------------------------------------------------------------------------
470
+
471
+ /**
472
+ * Turn a token response into the shape we persist. `expires_at` is absolute so
473
+ * a config file read an hour later is still interpretable.
474
+ *
475
+ * @param {unknown} body
476
+ * @param {number} [now]
477
+ */
478
+ export function normalizeTokenResponse(body, now = Date.now()) {
479
+ const b = body && typeof body === 'object' ? body : {};
480
+ const expiresIn = Number.isFinite(Number(b.expires_in)) ? Number(b.expires_in) : DEFAULT_EXPIRES_IN_S;
481
+ return {
482
+ access_token: str(b.access_token),
483
+ refresh_token: str(b.refresh_token) || null,
484
+ token_type: str(b.token_type) || 'Bearer',
485
+ scope: str(b.scope) || null,
486
+ expires_in: expiresIn,
487
+ expires_at: new Date(now + expiresIn * 1000).toISOString(),
488
+ };
489
+ }
490
+
491
+ async function postToken({ tokenEndpoint, params, fetchImpl, timeoutMs, now }) {
492
+ const host = hostOf(tokenEndpoint);
493
+ const form = new URLSearchParams();
494
+ for (const [k, v] of Object.entries(params)) {
495
+ if (v != null && v !== '') form.set(k, String(v));
496
+ }
497
+
498
+ const res = await requestJson(tokenEndpoint, {
499
+ method: 'POST',
500
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
501
+ body: form.toString(),
502
+ fetchImpl,
503
+ timeoutMs,
504
+ });
505
+
506
+ if (res.networkError) {
507
+ return { ok: false, code: 'unreachable', message: `anyslate: cannot reach ${host} to exchange tokens — ${res.detail}.` };
508
+ }
509
+ if (!res.ok) {
510
+ return {
511
+ ok: false,
512
+ code: str(res.body?.error) || 'token_request_failed',
513
+ status: res.status,
514
+ message: `anyslate: ${host} rejected the token request (HTTP ${res.status}) — ${oauthErrorText(res.body, res.text?.slice(0, 200) || 'no detail')}.`,
515
+ };
516
+ }
517
+ const tokens = normalizeTokenResponse(res.body, now);
518
+ if (!tokens.access_token) {
519
+ return { ok: false, code: 'no_access_token', message: `anyslate: ${host} returned no access_token.` };
520
+ }
521
+ return { ok: true, tokens, raw: res.body };
522
+ }
523
+
524
+ /**
525
+ * @param {{tokenEndpoint: string, code: string, redirectUri: string, codeVerifier: string,
526
+ * clientId: string, resource: string, fetchImpl?: typeof fetch, timeoutMs?: number, now?: number}} opts
527
+ */
528
+ export function exchangeCode({ tokenEndpoint, code, redirectUri, codeVerifier, clientId, resource, fetchImpl = fetch, timeoutMs = 15_000, now }) {
529
+ return postToken({
530
+ tokenEndpoint,
531
+ params: {
532
+ grant_type: 'authorization_code',
533
+ code,
534
+ redirect_uri: redirectUri,
535
+ code_verifier: codeVerifier,
536
+ client_id: clientId,
537
+ resource,
538
+ },
539
+ fetchImpl,
540
+ timeoutMs,
541
+ now,
542
+ });
543
+ }
544
+
545
+ /**
546
+ * Refresh rotation IS enabled server-side (oauth_refresh_tokens.replaced_by_id),
547
+ * so the caller MUST persist `tokens.refresh_token` immediately — the old one is
548
+ * dead the moment this resolves.
549
+ *
550
+ * @param {{tokenEndpoint: string, refreshToken: string, clientId: string, resource: string,
551
+ * fetchImpl?: typeof fetch, timeoutMs?: number, now?: number}} opts
552
+ */
553
+ export function refreshAccessToken({ tokenEndpoint, refreshToken, clientId, resource, fetchImpl = fetch, timeoutMs = 15_000, now }) {
554
+ return postToken({
555
+ tokenEndpoint,
556
+ params: {
557
+ grant_type: 'refresh_token',
558
+ refresh_token: refreshToken,
559
+ client_id: clientId,
560
+ resource,
561
+ },
562
+ fetchImpl,
563
+ timeoutMs,
564
+ now,
565
+ });
566
+ }
567
+
568
+ /**
569
+ * Best effort — a revocation that fails must never stop `logout` from deleting
570
+ * the local credential, or the user is stuck with a file they cannot clear.
571
+ *
572
+ * @param {{revocationEndpoint: string, token: string, clientId?: string,
573
+ * tokenTypeHint?: string, fetchImpl?: typeof fetch, timeoutMs?: number}} opts
574
+ * @returns {Promise<{ok: boolean, status?: number, detail?: string}>}
575
+ */
576
+ export async function revokeToken({ revocationEndpoint, token, clientId, tokenTypeHint, fetchImpl = fetch, timeoutMs = 8_000 }) {
577
+ const form = new URLSearchParams();
578
+ form.set('token', token);
579
+ if (clientId) form.set('client_id', clientId);
580
+ if (tokenTypeHint) form.set('token_type_hint', tokenTypeHint);
581
+
582
+ const res = await requestJson(revocationEndpoint, {
583
+ method: 'POST',
584
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
585
+ body: form.toString(),
586
+ fetchImpl,
587
+ timeoutMs,
588
+ });
589
+ if (res.networkError) return { ok: false, detail: res.detail };
590
+ return { ok: !!res.ok, status: res.status, detail: res.ok ? undefined : oauthErrorText(res.body, `HTTP ${res.status}`) };
591
+ }
592
+
593
+ /** The conventional path when discovery does not advertise `revocation_endpoint`. */
594
+ export function revocationEndpointFor(discovery, root) {
595
+ return discovery?.revocationEndpoint || `${String(root).replace(/\/+$/, '')}/oauth/revoke`;
596
+ }
597
+
598
+ // ---------------------------------------------------------------------------
599
+ // Browser
600
+ // ---------------------------------------------------------------------------
601
+
602
+ /**
603
+ * @param {string} url
604
+ * @param {{platform?: string, spawnImpl?: typeof spawn}} [deps]
605
+ * @returns {{ok: boolean, command?: string, error?: string}}
606
+ */
607
+ export function openBrowser(url, deps = {}) {
608
+ const platform = deps.platform ?? process.platform;
609
+ const spawnImpl = deps.spawnImpl ?? spawn;
610
+
611
+ let cmd;
612
+ let args;
613
+ if (platform === 'darwin') {
614
+ cmd = 'open';
615
+ args = [url];
616
+ } else if (platform === 'win32') {
617
+ // The empty "" is the window title `start` otherwise steals from the URL.
618
+ cmd = 'cmd';
619
+ args = ['/c', 'start', '', url];
620
+ } else {
621
+ cmd = 'xdg-open';
622
+ args = [url];
623
+ }
624
+
625
+ try {
626
+ const child = spawnImpl(cmd, args, { stdio: 'ignore', detached: true });
627
+ child?.unref?.();
628
+ child?.on?.('error', () => {});
629
+ return { ok: true, command: cmd };
630
+ } catch (e) {
631
+ return { ok: false, command: cmd, error: String(e?.message ?? e) };
632
+ }
633
+ }