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