@moda-ai/cli 1.13.0 → 1.13.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/dist/auth.js CHANGED
@@ -1,11 +1,20 @@
1
- // src/auth.ts — Browser-based authentication for moda init via frontend
1
+ // src/auth.ts — Browser-based authentication for moda init via frontend.
2
+ // Owns the interactive login flow (localhost callback server, PKCE exchange,
3
+ // manual paste fallback) and authFetch. Session persistence lives in
4
+ // ./auth-storage.ts; the moda_ct renew/revoke/401-classification lifecycle
5
+ // lives in ./cli-session.ts — both re-exported here so auth.js stays the
6
+ // single import surface for auth.
2
7
  import { createServer } from 'node:http';
3
- import { existsSync, readFileSync, rmSync } from 'node:fs';
4
- import { randomBytes, timingSafeEqual } from 'node:crypto';
8
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
9
+ import { hostname } from 'node:os';
5
10
  import { createInterface } from 'node:readline';
6
11
  import { resolveModaBaseUrl } from './config.js';
7
- import { CliAuthError } from './errors.js';
8
- import { getModaPaths, loadProfileConfig, saveProfileConfig, secretFilePathFromRef, writeSecretJsonFile, } from './profiles.js';
12
+ import { CliAuthError, CliInputError } from './errors.js';
13
+ import { loadProfileConfig } from './profiles.js';
14
+ import { isModaCtSession, loadAuthSession, persistAuthSession, resolveStorageProfile, saveAuthSession, } from './auth-storage.js';
15
+ import { classifyModaCtUnauthorized, cliSessionExpiredError, scheduleRenewalIfNearExpiry, } from './cli-session.js';
16
+ export { clearAuthSession, isAuthSessionValid, loadAuthSession, loadToken, saveAuthSession, } from './auth-storage.js';
17
+ export { revokeCliSessionToken } from './cli-session.js';
9
18
  const CALLBACK_PORT = 8432;
10
19
  // Fallback ports tried in order when CALLBACK_PORT is taken. The frontend
11
20
  // callback redirect is port-parameterized, so any of these works as long as
@@ -13,6 +22,8 @@ const CALLBACK_PORT = 8432;
13
22
  const CALLBACK_PORT_LADDER = [CALLBACK_PORT, 8433, 8434, 8435];
14
23
  const AUTH_TIMEOUT_MS = 120_000;
15
24
  const DEFAULT_EXPIRES_IN_SECONDS = 86_400;
25
+ // PKCE code exchange against {base}/api/cli/auth/exchange.
26
+ const EXCHANGE_TIMEOUT_MS = 10_000;
16
27
  function escapeHtml(value) {
17
28
  return value
18
29
  .replace(/&/g, '&')
@@ -28,140 +39,52 @@ function safeEqual(a, b) {
28
39
  return false;
29
40
  return timingSafeEqual(aBuf, bBuf);
30
41
  }
31
- function tokenPath(options = {}) {
32
- return getModaPaths(options).legacyAuthPath;
42
+ /**
43
+ * Classify a bare paste as a PKCE one-time code vs a legacy token. One-time
44
+ * codes are base64url(32 random bytes) = 43 chars with no dots; legacy Auth0
45
+ * access tokens are JWTs (dot-separated segments, hundreds of chars). Only
46
+ * pastes that plausibly ARE codes (base64url alphabet, code-sized) go down
47
+ * the exchange path — everything else keeps the legacy bare-token behavior.
48
+ * Collision analysis: every legacy Moda credential is a JWT, and a JWT always
49
+ * contains dots, so the dot-free 32-96 char band cannot match one — a paste
50
+ * classified as a code here is never a misrouted legacy token.
51
+ */
52
+ function looksLikeOneTimeCode(value) {
53
+ return /^[A-Za-z0-9_-]{32,96}$/.test(value);
33
54
  }
34
- function authTokenRefForProfile(profile) {
35
- return `file:secrets/auth/${profile}.json`;
36
- }
37
- function parseStoredAuth(value) {
38
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
39
- return null;
55
+ class CodeExchangeError extends Error {
56
+ reason;
57
+ constructor(message, reason) {
58
+ super(message);
59
+ this.reason = reason;
60
+ this.name = 'CodeExchangeError';
40
61
  }
41
- const data = value;
42
- if (typeof data.access_token !== 'string' ||
43
- typeof data.expires_at !== 'number') {
44
- return null;
45
- }
46
- return {
47
- access_token: data.access_token,
48
- expires_at: data.expires_at,
49
- profile: typeof data.profile === 'string' ? data.profile : undefined,
50
- token_type: data.token_type === 'bearer' ? 'bearer' : undefined,
51
- };
52
- }
53
- export function isAuthSessionValid(data, now = Date.now()) {
54
- if (!data)
55
- return false;
56
- return now / 1000 <= data.expires_at - 60;
57
62
  }
58
- function readStoredAuth(path) {
59
- try {
60
- if (!existsSync(path))
61
- return null;
62
- return parseStoredAuth(JSON.parse(readFileSync(path, 'utf8')));
63
- }
64
- catch {
65
- return null;
66
- }
63
+ function exchangeFailureReason(error) {
64
+ return error instanceof CodeExchangeError ? error.reason : 'exchange_failed';
67
65
  }
68
- export function loadToken(options = {}) {
69
- return loadAuthSession(options)?.access_token ?? null;
66
+ /** base64url(sha256(verifier)) — the S256 code challenge for a verifier. */
67
+ export function computePkceChallenge(codeVerifier) {
68
+ return createHash('sha256').update(codeVerifier).digest('base64url');
70
69
  }
71
- export function loadAuthSession(options = {}) {
72
- const profileSession = loadProfileAuthSession(options);
73
- if (profileSession)
74
- return profileSession;
75
- const legacy = readStoredAuth(tokenPath(options));
76
- if (!legacy)
77
- return null;
78
- if (!options.includeExpired && !isAuthSessionValid(legacy)) {
79
- return null;
80
- }
81
- return legacy;
70
+ function generatePkceVerifier() {
71
+ // 32 random bytes -> 43 base64url chars, within the RFC 7636 43-128 range.
72
+ return randomBytes(32).toString('base64url');
82
73
  }
83
- function loadProfileAuthSession(options) {
84
- const profileName = options.profile ?? (options.env ?? process.env).MODA_PROFILE;
85
- if (!profileName)
74
+ /**
75
+ * The most recent PKCE exchange response for THIS process, so `moda init`
76
+ * can skip one /api/cli/context round-trip right after login. Never
77
+ * persisted to disk. Keyed by the storage profile the login targeted, and
78
+ * cleared at the start of every login(), so a context from one profile's
79
+ * exchange can never leak into another profile's init.
80
+ */
81
+ let cachedLoginContext = null;
82
+ export function getCachedLoginContext(options = {}) {
83
+ if (!cachedLoginContext)
86
84
  return null;
87
- try {
88
- const profile = loadProfileConfig(options).profiles[profileName];
89
- const tokenRef = profile?.auth_token_ref;
90
- if (!tokenRef)
91
- return null;
92
- const path = secretFilePathFromRef(tokenRef, options);
93
- if (!path)
94
- return null;
95
- const session = readStoredAuth(path);
96
- if (!session)
97
- return null;
98
- if (!options.includeExpired && !isAuthSessionValid(session))
99
- return null;
100
- return session;
101
- }
102
- catch {
85
+ if (cachedLoginContext.profile !== resolveStorageProfile(options))
103
86
  return null;
104
- }
105
- }
106
- export function saveAuthSession(accessToken, expiresIn, options = {}) {
107
- const profile = options.profile ?? (options.env ?? process.env).MODA_PROFILE;
108
- const data = {
109
- access_token: accessToken,
110
- expires_at: Math.floor(Date.now() / 1000) + expiresIn,
111
- profile,
112
- token_type: 'bearer',
113
- };
114
- if (profile) {
115
- const ref = authTokenRefForProfile(profile);
116
- const path = secretFilePathFromRef(ref, options);
117
- if (!path)
118
- throw new Error(`Unsupported auth token reference: ${ref}`);
119
- const config = loadProfileConfig(options);
120
- const existing = config.profiles[profile];
121
- if (!existing) {
122
- throw new Error(`Moda profile '${profile}' does not exist.`);
123
- }
124
- writeSecretJsonFile(path, data);
125
- saveProfileConfig({
126
- ...config,
127
- profiles: {
128
- ...config.profiles,
129
- [profile]: {
130
- ...existing,
131
- auth_token_ref: ref,
132
- },
133
- },
134
- }, options);
135
- return data;
136
- }
137
- writeSecretJsonFile(tokenPath(options), data);
138
- return data;
139
- }
140
- export function clearAuthSession(options = {}) {
141
- const profile = options.profile ?? (options.env ?? process.env).MODA_PROFILE;
142
- if (profile) {
143
- const ref = authTokenRefForProfile(profile);
144
- const path = secretFilePathFromRef(ref, options);
145
- if (path)
146
- rmSync(path, { force: true });
147
- const config = loadProfileConfig(options);
148
- const existing = config.profiles[profile];
149
- if (existing) {
150
- const { auth_token_ref: _drop, ...profileWithoutToken } = existing;
151
- saveProfileConfig({
152
- ...config,
153
- profiles: {
154
- ...config.profiles,
155
- [profile]: profileWithoutToken,
156
- },
157
- }, options);
158
- }
159
- return;
160
- }
161
- rmSync(tokenPath(options), { force: true });
162
- }
163
- function saveToken(accessToken, expiresIn, options = {}) {
164
- saveAuthSession(accessToken, expiresIn, options);
87
+ return cachedLoginContext.context;
165
88
  }
166
89
  // --- Login Flow ---
167
90
  const SUCCESS_HTML = `<!DOCTYPE html>
@@ -201,8 +124,10 @@ function closeAuthServer(server) {
201
124
  }
202
125
  export async function login(screen = 'login', options = {}) {
203
126
  return new Promise((resolve, reject) => {
127
+ // A fresh login invalidates any context cached by a previous login in
128
+ // this process (which may have targeted a different profile).
129
+ cachedLoginContext = null;
204
130
  let settled = false;
205
- const state = randomBytes(16).toString('hex');
206
131
  // An explicit callbackPort is used as-is (tests bind port 0 for an
207
132
  // ephemeral port); only the default flow walks the fallback ladder.
208
133
  const portsToTry = options.callbackPort !== undefined
@@ -211,19 +136,106 @@ export async function login(screen = 'login', options = {}) {
211
136
  let portIndex = 0;
212
137
  let localPort = portsToTry[0];
213
138
  let timeout;
214
- // Manual-paste fallback state (used when the browser can't reach this box).
215
139
  let promptInput;
140
+ let promptStream;
216
141
  let promptStreamIsTTY = false;
217
- // Holds a bare token awaiting the interactive [y/N] confirmation.
218
- let pendingBareToken = null;
142
+ let pendingBarePaste = null;
143
+ let exchangeInFlight = false;
144
+ const state = randomBytes(16).toString('hex');
145
+ // PKCE: the verifier never leaves this process; only its S256 challenge
146
+ // rides the auth URL. An old deployed frontend ignores the challenge
147
+ // params and falls back to the legacy fragment flow, which the /callback
148
+ // handler below still supports.
149
+ const codeVerifier = generatePkceVerifier();
150
+ const codeChallenge = computePkceChallenge(codeVerifier);
151
+ // Every token save site (HTTP callback and manual paste) must honor the
152
+ // caller's profile context, or `--profile X` logins land in the legacy
153
+ // ~/.moda/auth.json instead of ~/.moda/secrets/auth/X.json.
154
+ const storageOptions = {
155
+ home: options.home,
156
+ profile: options.profile,
157
+ now: options.now,
158
+ env: options.env,
159
+ };
160
+ // Fail fast on an unknown profile BEFORE binding the callback server or
161
+ // opening the browser: deferring to the save at the end of the flow would
162
+ // complete the whole browser roundtrip only to throw from inside the
163
+ // request handler (unhandled) and leave the user's tab hanging.
164
+ const targetProfile = options.profile ?? (options.env ?? process.env).MODA_PROFILE;
165
+ if (targetProfile && !loadProfileConfig(storageOptions).profiles[targetProfile]) {
166
+ reject(new CliInputError(`Moda profile '${targetProfile}' does not exist.`, 'Create it with "moda profiles create <name>" or check "moda profiles list".'));
167
+ return;
168
+ }
219
169
  const server = createServer(async (req, res) => {
220
- if (settled)
170
+ if (settled) {
171
+ // Answer stragglers instead of leaving the socket dangling: an
172
+ // unanswered in-flight request would hold the event loop open.
173
+ res.writeHead(410, { Connection: 'close' });
174
+ res.end('Login attempt already completed');
221
175
  return;
176
+ }
222
177
  const url = new URL(req.url || '/', `http://localhost:${localPort}`);
223
- // Serve fragment extractor at /callback — the frontend redirects here
224
- // with token in the URL fragment (#token=...) which never reaches the server.
225
- // This page extracts it client-side and forwards to /callback-token as query params.
226
178
  if (url.pathname === '/callback') {
179
+ const code = url.searchParams.get('code');
180
+ if (code) {
181
+ // PKCE path: the frontend redirected with ?code=&state= query
182
+ // params (no fragment hop). Validate state, then exchange the
183
+ // one-time code + verifier for a moda_ct session token.
184
+ const callbackState = url.searchParams.get('state');
185
+ if (!callbackState || !safeEqual(callbackState, state)) {
186
+ res.writeHead(400, { 'Content-Type': 'text/html', Connection: 'close' });
187
+ res.end('<html><body><h2>Error</h2><p>Invalid authentication state.</p></body></html>', () => {
188
+ fail(new Error('Invalid authentication state.'));
189
+ });
190
+ return;
191
+ }
192
+ if (exchangeInFlight) {
193
+ res.writeHead(409, { Connection: 'close' });
194
+ res.end('Login code exchange already in progress');
195
+ return;
196
+ }
197
+ exchangeInFlight = true;
198
+ void (async () => {
199
+ // A PKCE grant implies a frontend deployment new enough to host
200
+ // /auth/cli/done, so the browser is bounced to the branded page
201
+ // instead of inline HTML. Only fixed, token-free params ride the
202
+ // Location header — never the code, verifier, token, or state.
203
+ try {
204
+ const cliToken = await exchangeCodeForSession(code);
205
+ // Leave exchangeInFlight true on success: succeed() only runs
206
+ // in the res.end flush callback, so resetting here (or in a
207
+ // finally) would open a window where a second valid-state
208
+ // callback could start a concurrent exchange before `settled`
209
+ // flips. The flag only needs to clear on failure, where a
210
+ // retry paste is still legitimate.
211
+ res.writeHead(302, {
212
+ Location: `${resolveModaBaseUrl()}/auth/cli/done?status=success`,
213
+ Connection: 'close',
214
+ });
215
+ res.end(() => {
216
+ succeed(cliToken);
217
+ });
218
+ }
219
+ catch (err) {
220
+ exchangeInFlight = false;
221
+ const error = err instanceof Error ? err : new Error(String(err));
222
+ res.writeHead(302, {
223
+ Location: `${resolveModaBaseUrl()}/auth/cli/done` +
224
+ `?status=error&reason=${exchangeFailureReason(err)}`,
225
+ Connection: 'close',
226
+ });
227
+ res.end(() => {
228
+ fail(error);
229
+ });
230
+ }
231
+ })();
232
+ return;
233
+ }
234
+ // Legacy fallback: an old deployed frontend (no PKCE support) ignores
235
+ // the code_challenge params and redirects here with the token in the
236
+ // URL fragment (#token=...), which never reaches the server. Serve
237
+ // the extractor page that forwards it to /callback-token as query
238
+ // params.
227
239
  res.writeHead(200, { 'Content-Type': 'text/html', Connection: 'close' });
228
240
  res.end(FRAGMENT_EXTRACTOR_HTML);
229
241
  return;
@@ -261,10 +273,19 @@ export async function login(screen = 'login', options = {}) {
261
273
  });
262
274
  return;
263
275
  }
264
- saveToken(token, parseInt(expiresIn || '86400', 10), {
265
- profile: options.profile,
266
- env: options.env,
267
- });
276
+ // Backstop for the fail-fast profile check above: a save failure must
277
+ // reject the login promise (not crash the process) and still answer the
278
+ // browser so the tab does not hang.
279
+ try {
280
+ saveAuthSession(token, parseInt(expiresIn || String(DEFAULT_EXPIRES_IN_SECONDS), 10), storageOptions);
281
+ }
282
+ catch (err) {
283
+ res.writeHead(500, { 'Content-Type': 'text/html', Connection: 'close' });
284
+ res.end('<html><body><h2>Error</h2><p>Authenticated, but the CLI could not store the token. Return to your terminal.</p></body></html>', () => {
285
+ fail(err instanceof Error ? err : new Error(String(err)));
286
+ });
287
+ return;
288
+ }
268
289
  res.writeHead(200, { 'Content-Type': 'text/html', Connection: 'close' });
269
290
  res.end(SUCCESS_HTML, () => {
270
291
  succeed(token);
@@ -277,9 +298,13 @@ export async function login(screen = 'login', options = {}) {
277
298
  function closePromptInput() {
278
299
  if (!promptInput)
279
300
  return;
280
- promptInput.removeListener('line', handlePastedLine);
281
301
  promptInput.close();
282
302
  promptInput = undefined;
303
+ if (promptStream === process.stdin) {
304
+ // Let the process exit naturally once auth settles.
305
+ process.stdin.pause();
306
+ }
307
+ promptStream = undefined;
283
308
  }
284
309
  function finish(callback) {
285
310
  if (settled)
@@ -301,32 +326,142 @@ export async function login(screen = 'login', options = {}) {
301
326
  function fail(error) {
302
327
  finish(() => reject(error));
303
328
  }
304
- function completeWithToken(token, expiresIn) {
305
- saveToken(token, expiresIn, { profile: options.profile, env: options.env });
306
- succeed(token);
307
- }
308
329
  function handleSigint() {
309
330
  fail(new Error('Authentication cancelled.'));
310
331
  }
311
332
  function handleSigterm() {
312
333
  fail(new Error('Authentication terminated.'));
313
334
  }
335
+ function completeWithToken(token, expiresIn) {
336
+ // Backstop: this runs inside a readline 'line' handler, where an
337
+ // uncaught save error would crash the process instead of rejecting.
338
+ try {
339
+ saveAuthSession(token, expiresIn, storageOptions);
340
+ }
341
+ catch (err) {
342
+ fail(err instanceof Error ? err : new Error(String(err)));
343
+ return;
344
+ }
345
+ succeed(token);
346
+ }
347
+ /**
348
+ * PKCE exchange: POST the one-time code + verifier and store the
349
+ * resulting moda_ct session. Throws with a user-facing message on any
350
+ * failure; the raw code and token are never logged.
351
+ */
352
+ async function exchangeCodeForSession(code) {
353
+ let response;
354
+ try {
355
+ // redirect: 'manual' — never follow a redirect on this POST: a
356
+ // 307/308 would re-send the one-time code and verifier to whatever
357
+ // host the Location header names. Any redirect is treated as a hard
358
+ // failure below (clearer than redirect:'error', whose rejection is
359
+ // indistinguishable from a network failure in the catch).
360
+ response = await fetch(`${resolveModaBaseUrl(storageOptions)}/api/cli/auth/exchange`, {
361
+ method: 'POST',
362
+ headers: { 'Content-Type': 'application/json' },
363
+ body: JSON.stringify({
364
+ code,
365
+ code_verifier: codeVerifier,
366
+ label: hostname(),
367
+ }),
368
+ redirect: 'manual',
369
+ signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS),
370
+ });
371
+ }
372
+ catch {
373
+ throw new CodeExchangeError('Could not reach the Moda API to exchange the login code. Check your network and try again.', 'unreachable');
374
+ }
375
+ if (response.status >= 300 && response.status < 400) {
376
+ throw new CodeExchangeError(`The Moda API redirected the login code exchange (HTTP ${response.status}). ` +
377
+ 'Refusing to re-send the login code across a redirect. ' +
378
+ 'Check MODA_BASE_URL (use https://moda.dev, not www.moda.dev) and try again.', 'exchange_failed');
379
+ }
380
+ if (response.status === 401) {
381
+ throw new CodeExchangeError('The login code was rejected (invalid, expired, or already used). Run the login command again.', 'code_rejected');
382
+ }
383
+ if (!response.ok) {
384
+ throw new CodeExchangeError(`Login code exchange failed (HTTP ${response.status}). Try again.`, 'exchange_failed');
385
+ }
386
+ let payload;
387
+ try {
388
+ payload = (await response.json());
389
+ }
390
+ catch {
391
+ throw new CodeExchangeError('The login code exchange returned an unreadable response. Try again.', 'exchange_failed');
392
+ }
393
+ const cliToken = typeof payload.cli_token === 'string' ? payload.cli_token : undefined;
394
+ const expiresAtMs = typeof payload.expires_at === 'string' ? Date.parse(payload.expires_at) : NaN;
395
+ if (!cliToken || !Number.isFinite(expiresAtMs)) {
396
+ throw new CodeExchangeError('The login code exchange returned an incomplete response. Try again.', 'exchange_failed');
397
+ }
398
+ const userEmail = typeof payload.user?.email === 'string' ? payload.user.email : undefined;
399
+ persistAuthSession({
400
+ access_token: cliToken,
401
+ expires_at: Math.floor(expiresAtMs / 1000),
402
+ profile: resolveStorageProfile(storageOptions),
403
+ token_type: 'bearer',
404
+ token_kind: 'moda_ct',
405
+ ...(userEmail ? { user_email: userEmail } : {}),
406
+ }, storageOptions);
407
+ cachedLoginContext = {
408
+ profile: resolveStorageProfile(storageOptions),
409
+ context: {
410
+ user: {
411
+ email: userEmail ?? '',
412
+ name: typeof payload.user?.name === 'string' ? payload.user.name : null,
413
+ },
414
+ tenants: Array.isArray(payload.tenants)
415
+ ? payload.tenants
416
+ : [],
417
+ },
418
+ };
419
+ return cliToken;
420
+ }
421
+ /**
422
+ * Paste-path variant of the exchange: converts throws into fail().
423
+ * Shares the exchangeInFlight guard with the HTTP callback path so a
424
+ * double paste (or a paste racing the browser callback) never runs two
425
+ * concurrent exchanges; the duplicate line is simply dropped and the
426
+ * first exchange settles the login.
427
+ */
428
+ async function completeWithCode(code) {
429
+ if (exchangeInFlight)
430
+ return;
431
+ exchangeInFlight = true;
432
+ try {
433
+ const cliToken = await exchangeCodeForSession(code);
434
+ succeed(cliToken);
435
+ }
436
+ catch (err) {
437
+ exchangeInFlight = false;
438
+ fail(err instanceof Error ? err : new Error(String(err)));
439
+ }
440
+ }
314
441
  // Accepts a line pasted by the user when the browser cannot reach this
315
- // machine: a full /callback-token?token=... URL, a /callback#token=...
316
- // fragment URL, or a bare token string.
442
+ // machine: a full PKCE /callback?code=... URL, a /callback-token?token=...
443
+ // URL, a /callback#token=... fragment URL, a bare one-time code (copied
444
+ // from the consent page), or a bare token string.
317
445
  function handlePastedLine(line) {
318
446
  if (settled)
319
447
  return;
320
448
  const trimmed = line.trim();
321
- if (pendingBareToken !== null) {
322
- const token = pendingBareToken;
323
- pendingBareToken = null;
449
+ if (pendingBarePaste !== null) {
450
+ const pending = pendingBarePaste;
451
+ pendingBarePaste = null;
324
452
  if (/^y(es)?$/i.test(trimmed)) {
325
- completeWithToken(token, DEFAULT_EXPIRES_IN_SECONDS);
453
+ if (pending.kind === 'code') {
454
+ void completeWithCode(pending.value);
455
+ }
456
+ else {
457
+ completeWithToken(pending.value, DEFAULT_EXPIRES_IN_SECONDS);
458
+ }
326
459
  }
327
460
  else {
328
461
  // A bare Enter is the conventional No for a [y/N] prompt.
329
- fail(new Error('Bare token rejected: state cannot be verified.'));
462
+ fail(new Error(pending.kind === 'code'
463
+ ? 'Pasted code rejected: state cannot be verified.'
464
+ : 'Bare token rejected: state cannot be verified.'));
330
465
  }
331
466
  return;
332
467
  }
@@ -343,33 +478,46 @@ export async function login(screen = 'login', options = {}) {
343
478
  fail(new Error('Could not parse the pasted URL. Paste the full callback URL from the browser.'));
344
479
  return;
345
480
  }
346
- // Fragment URLs (/callback#token=...) carry the same key/value pairs as
347
- // the /callback-token query string; parse whichever is present.
481
+ // Fragment URLs (/callback#token=...) carry the same key/value pairs
482
+ // as the /callback-token query string; parse whichever is present.
348
483
  const params = pastedUrl.hash.length > 1
349
484
  ? new URLSearchParams(pastedUrl.hash.slice(1))
350
485
  : pastedUrl.searchParams;
351
486
  const token = params.get('token');
487
+ const code = params.get('code');
352
488
  const expiresIn = params.get('expires_in');
353
489
  const pastedState = params.get('state');
354
490
  if (!pastedState || !safeEqual(pastedState, state)) {
355
491
  fail(new Error('Invalid authentication state.'));
356
492
  return;
357
493
  }
358
- if (!token) {
359
- fail(new Error('No token received'));
494
+ if (token) {
495
+ completeWithToken(token, parseInt(expiresIn || String(DEFAULT_EXPIRES_IN_SECONDS), 10));
496
+ return;
497
+ }
498
+ if (code) {
499
+ // New-style PKCE callback URL pasted manually: state verified
500
+ // above, so exchange the one-time code like the HTTP path does.
501
+ void completeWithCode(code);
360
502
  return;
361
503
  }
362
- completeWithToken(token, parseInt(expiresIn || String(DEFAULT_EXPIRES_IN_SECONDS), 10));
504
+ fail(new Error('No token received'));
363
505
  return;
364
506
  }
365
- // Bare token: there is no state to verify, so require an explicit,
366
- // interactive confirmation before trusting it.
507
+ // Bare paste. A one-time code (the consent page renders a copy block)
508
+ // has no state to verify either, so it gets the same explicit
509
+ // interactive confirmation gate as a bare token.
510
+ const kind = looksLikeOneTimeCode(trimmed) ? 'code' : 'token';
367
511
  if (promptStreamIsTTY) {
368
- pendingBareToken = trimmed;
369
- console.error(' State cannot be verified for a bare token. Continue? [y/N]');
512
+ pendingBarePaste = { kind, value: trimmed };
513
+ console.error(kind === 'code'
514
+ ? ' State cannot be verified for a pasted code. Continue? [y/N]'
515
+ : ' State cannot be verified for a bare token. Continue? [y/N]');
370
516
  return;
371
517
  }
372
- fail(new Error('State cannot be verified for a bare token in non-interactive mode. Paste the full callback URL instead.'));
518
+ fail(new Error(kind === 'code'
519
+ ? 'State cannot be verified for a pasted code in non-interactive mode. Paste the full callback URL instead.'
520
+ : 'State cannot be verified for a bare token in non-interactive mode. Paste the full callback URL instead.'));
373
521
  }
374
522
  function attachPromptInput() {
375
523
  if (settled || promptInput)
@@ -380,6 +528,7 @@ export async function login(screen = 'login', options = {}) {
380
528
  : undefined);
381
529
  if (!stream)
382
530
  return;
531
+ promptStream = stream;
383
532
  promptStreamIsTTY = Boolean(stream.isTTY);
384
533
  promptInput = createInterface({ input: stream, terminal: false });
385
534
  promptInput.on('line', handlePastedLine);
@@ -412,7 +561,7 @@ export async function login(screen = 'login', options = {}) {
412
561
  fail(err);
413
562
  }
414
563
  });
415
- server.listen(localPort, '127.0.0.1', async () => {
564
+ async function onListening() {
416
565
  if (settled)
417
566
  return;
418
567
  const address = server.address();
@@ -422,7 +571,11 @@ export async function login(screen = 'login', options = {}) {
422
571
  // Resolve the auth URL with the same profile/env context so `--profile
423
572
  // staging` opens the staging flow (not whatever the active profile is)
424
573
  // and the minted session matches where it will be stored.
425
- const authUrl = `${resolveModaBaseUrl({ profile: options.profile, env: options.env })}/auth/cli?port=${localPort}&screen=${screen}&state=${state}`;
574
+ // code_challenge/challenge_method upgrade the frontend to the PKCE
575
+ // consent flow; an old deployed frontend ignores them and runs the
576
+ // legacy fragment flow, which the /callback handler still accepts.
577
+ const authUrl = `${resolveModaBaseUrl(storageOptions)}/auth/cli?port=${localPort}&screen=${screen}&state=${state}` +
578
+ `&code_challenge=${codeChallenge}&challenge_method=S256`;
426
579
  attachPromptInput();
427
580
  try {
428
581
  await (options.openUrl ?? openAuthUrl)(authUrl);
@@ -432,21 +585,37 @@ export async function login(screen = 'login', options = {}) {
432
585
  }
433
586
  console.error(` Open this URL in your browser if it did not open automatically:\n ${authUrl}`);
434
587
  console.error(" Paste the callback URL or code here if the browser can't reach this machine\n" +
435
- "(if the browser page failed to redirect, copy the URL from the browser's address bar):");
436
- });
588
+ " (if the browser page failed to redirect, copy the URL from the browser's address bar):");
589
+ }
590
+ // Register the listening handler exactly once: passing a callback to each
591
+ // server.listen() call would accumulate once('listening') handlers across
592
+ // ladder retries, and every accumulated callback would fire on the
593
+ // eventual successful bind (opening the browser multiple times).
594
+ server.once('listening', onListening);
595
+ server.listen(localPort, '127.0.0.1');
437
596
  process.once('SIGINT', handleSigint);
438
597
  process.once('SIGTERM', handleSigterm);
439
598
  });
440
599
  }
441
600
  // --- Authenticated Fetch ---
442
601
  export async function authFetch(path, options = {}, authOptions = {}) {
443
- const token = loadToken(authOptions);
444
- if (!token) {
445
- throw new CliAuthError('Not authenticated. Run "moda auth login" to log in.', {
602
+ const session = loadAuthSession(authOptions);
603
+ if (!session) {
604
+ // An expired moda_ct session is a classifiable auth failure; legacy
605
+ // sessions keep the historical plain error.
606
+ const expired = loadAuthSession({ ...authOptions, includeExpired: true });
607
+ if (expired && isModaCtSession(expired)) {
608
+ throw cliSessionExpiredError();
609
+ }
610
+ throw new CliAuthError('Not authenticated. Run "moda init" to log in.', {
446
611
  reason: 'no_stored_credentials',
447
- login_command: ['moda', 'auth', 'login'],
612
+ login_command: ['moda', 'init'],
448
613
  });
449
614
  }
615
+ const token = session.access_token;
616
+ // Sliding expiry: renew opportunistically when a moda_ct session is inside
617
+ // the renewal window. Fire-and-forget; the request below never waits on it.
618
+ scheduleRenewalIfNearExpiry(session, authOptions);
450
619
  const headers = new Headers(options.headers);
451
620
  headers.set('Authorization', `Bearer ${token}`);
452
621
  if (!headers.has('Content-Type') && options.body) {
@@ -468,6 +637,14 @@ export async function authFetch(path, options = {}, authOptions = {}) {
468
637
  for (let hop = 0;; hop++) {
469
638
  const response = await fetch(url, { ...options, headers, redirect: 'manual' });
470
639
  if (response.status < 300 || response.status >= 400) {
640
+ if (response.status === 401 && isModaCtSession(session)) {
641
+ // 401 taxonomy for moda_ct tokens: expired vs revoked vs a transient
642
+ // renewal failure (each with distinct recovery guidance). Legacy
643
+ // auth0-kind sessions keep the historical pass-through behavior.
644
+ const authError = await classifyModaCtUnauthorized(session, authOptions);
645
+ if (authError)
646
+ throw authError;
647
+ }
471
648
  return response;
472
649
  }
473
650
  const location = response.headers.get('location');