@moda-ai/cli 1.13.0 → 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-storage.d.ts +35 -0
- package/dist/auth-storage.js +159 -0
- package/dist/auth.d.ts +17 -13
- package/dist/auth.js +332 -176
- package/dist/cli-session.d.ts +26 -0
- package/dist/cli-session.js +169 -0
- package/dist/init/index.js +8 -3
- package/dist/init/tenant.js +18 -7
- package/dist/profile-commands.js +10 -1
- package/package.json +1 -1
- package/skills/integration/index.json +2 -2
- package/skills/moda-cli/SKILL.md +116 -2
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 {
|
|
4
|
-
import {
|
|
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 {
|
|
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,42 @@ function safeEqual(a, b) {
|
|
|
28
39
|
return false;
|
|
29
40
|
return timingSafeEqual(aBuf, bBuf);
|
|
30
41
|
}
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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');
|
|
36
59
|
}
|
|
37
|
-
function
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
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;
|
|
60
|
+
function generatePkceVerifier() {
|
|
61
|
+
// 32 random bytes -> 43 base64url chars, within the RFC 7636 43-128 range.
|
|
62
|
+
return randomBytes(32).toString('base64url');
|
|
57
63
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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)
|
|
65
74
|
return null;
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
export function loadToken(options = {}) {
|
|
69
|
-
return loadAuthSession(options)?.access_token ?? null;
|
|
70
|
-
}
|
|
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;
|
|
82
|
-
}
|
|
83
|
-
function loadProfileAuthSession(options) {
|
|
84
|
-
const profileName = options.profile ?? (options.env ?? process.env).MODA_PROFILE;
|
|
85
|
-
if (!profileName)
|
|
75
|
+
if (cachedLoginContext.profile !== resolveStorageProfile(options))
|
|
86
76
|
return null;
|
|
87
|
-
|
|
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 {
|
|
103
|
-
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);
|
|
77
|
+
return cachedLoginContext.context;
|
|
165
78
|
}
|
|
166
79
|
// --- Login Flow ---
|
|
167
80
|
const SUCCESS_HTML = `<!DOCTYPE html>
|
|
@@ -201,8 +114,10 @@ function closeAuthServer(server) {
|
|
|
201
114
|
}
|
|
202
115
|
export async function login(screen = 'login', options = {}) {
|
|
203
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;
|
|
204
120
|
let settled = false;
|
|
205
|
-
const state = randomBytes(16).toString('hex');
|
|
206
121
|
// An explicit callbackPort is used as-is (tests bind port 0 for an
|
|
207
122
|
// ephemeral port); only the default flow walks the fallback ladder.
|
|
208
123
|
const portsToTry = options.callbackPort !== undefined
|
|
@@ -211,19 +126,95 @@ export async function login(screen = 'login', options = {}) {
|
|
|
211
126
|
let portIndex = 0;
|
|
212
127
|
let localPort = portsToTry[0];
|
|
213
128
|
let timeout;
|
|
214
|
-
// Manual-paste fallback state (used when the browser can't reach this box).
|
|
215
129
|
let promptInput;
|
|
130
|
+
let promptStream;
|
|
216
131
|
let promptStreamIsTTY = false;
|
|
217
|
-
|
|
218
|
-
let
|
|
132
|
+
let pendingBarePaste = null;
|
|
133
|
+
let exchangeInFlight = false;
|
|
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
|
+
}
|
|
219
159
|
const server = createServer(async (req, res) => {
|
|
220
|
-
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');
|
|
221
165
|
return;
|
|
166
|
+
}
|
|
222
167
|
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
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.
|
|
227
218
|
res.writeHead(200, { 'Content-Type': 'text/html', Connection: 'close' });
|
|
228
219
|
res.end(FRAGMENT_EXTRACTOR_HTML);
|
|
229
220
|
return;
|
|
@@ -261,10 +252,19 @@ export async function login(screen = 'login', options = {}) {
|
|
|
261
252
|
});
|
|
262
253
|
return;
|
|
263
254
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
+
}
|
|
268
268
|
res.writeHead(200, { 'Content-Type': 'text/html', Connection: 'close' });
|
|
269
269
|
res.end(SUCCESS_HTML, () => {
|
|
270
270
|
succeed(token);
|
|
@@ -277,9 +277,13 @@ export async function login(screen = 'login', options = {}) {
|
|
|
277
277
|
function closePromptInput() {
|
|
278
278
|
if (!promptInput)
|
|
279
279
|
return;
|
|
280
|
-
promptInput.removeListener('line', handlePastedLine);
|
|
281
280
|
promptInput.close();
|
|
282
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;
|
|
283
287
|
}
|
|
284
288
|
function finish(callback) {
|
|
285
289
|
if (settled)
|
|
@@ -301,32 +305,142 @@ export async function login(screen = 'login', options = {}) {
|
|
|
301
305
|
function fail(error) {
|
|
302
306
|
finish(() => reject(error));
|
|
303
307
|
}
|
|
304
|
-
function completeWithToken(token, expiresIn) {
|
|
305
|
-
saveToken(token, expiresIn, { profile: options.profile, env: options.env });
|
|
306
|
-
succeed(token);
|
|
307
|
-
}
|
|
308
308
|
function handleSigint() {
|
|
309
309
|
fail(new Error('Authentication cancelled.'));
|
|
310
310
|
}
|
|
311
311
|
function handleSigterm() {
|
|
312
312
|
fail(new Error('Authentication terminated.'));
|
|
313
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
|
+
}
|
|
314
420
|
// Accepts a line pasted by the user when the browser cannot reach this
|
|
315
|
-
// machine: a full /callback
|
|
316
|
-
// fragment URL,
|
|
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.
|
|
317
424
|
function handlePastedLine(line) {
|
|
318
425
|
if (settled)
|
|
319
426
|
return;
|
|
320
427
|
const trimmed = line.trim();
|
|
321
|
-
if (
|
|
322
|
-
const
|
|
323
|
-
|
|
428
|
+
if (pendingBarePaste !== null) {
|
|
429
|
+
const pending = pendingBarePaste;
|
|
430
|
+
pendingBarePaste = null;
|
|
324
431
|
if (/^y(es)?$/i.test(trimmed)) {
|
|
325
|
-
|
|
432
|
+
if (pending.kind === 'code') {
|
|
433
|
+
void completeWithCode(pending.value);
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
completeWithToken(pending.value, DEFAULT_EXPIRES_IN_SECONDS);
|
|
437
|
+
}
|
|
326
438
|
}
|
|
327
439
|
else {
|
|
328
440
|
// A bare Enter is the conventional No for a [y/N] prompt.
|
|
329
|
-
fail(new Error(
|
|
441
|
+
fail(new Error(pending.kind === 'code'
|
|
442
|
+
? 'Pasted code rejected: state cannot be verified.'
|
|
443
|
+
: 'Bare token rejected: state cannot be verified.'));
|
|
330
444
|
}
|
|
331
445
|
return;
|
|
332
446
|
}
|
|
@@ -343,33 +457,46 @@ export async function login(screen = 'login', options = {}) {
|
|
|
343
457
|
fail(new Error('Could not parse the pasted URL. Paste the full callback URL from the browser.'));
|
|
344
458
|
return;
|
|
345
459
|
}
|
|
346
|
-
// Fragment URLs (/callback#token=...) carry the same key/value pairs
|
|
347
|
-
// the /callback-token query string; parse whichever is present.
|
|
460
|
+
// Fragment URLs (/callback#token=...) carry the same key/value pairs
|
|
461
|
+
// as the /callback-token query string; parse whichever is present.
|
|
348
462
|
const params = pastedUrl.hash.length > 1
|
|
349
463
|
? new URLSearchParams(pastedUrl.hash.slice(1))
|
|
350
464
|
: pastedUrl.searchParams;
|
|
351
465
|
const token = params.get('token');
|
|
466
|
+
const code = params.get('code');
|
|
352
467
|
const expiresIn = params.get('expires_in');
|
|
353
468
|
const pastedState = params.get('state');
|
|
354
469
|
if (!pastedState || !safeEqual(pastedState, state)) {
|
|
355
470
|
fail(new Error('Invalid authentication state.'));
|
|
356
471
|
return;
|
|
357
472
|
}
|
|
358
|
-
if (
|
|
359
|
-
|
|
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);
|
|
360
481
|
return;
|
|
361
482
|
}
|
|
362
|
-
|
|
483
|
+
fail(new Error('No token received'));
|
|
363
484
|
return;
|
|
364
485
|
}
|
|
365
|
-
// Bare
|
|
366
|
-
//
|
|
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';
|
|
367
490
|
if (promptStreamIsTTY) {
|
|
368
|
-
|
|
369
|
-
console.error(
|
|
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]');
|
|
370
495
|
return;
|
|
371
496
|
}
|
|
372
|
-
fail(new Error(
|
|
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.'));
|
|
373
500
|
}
|
|
374
501
|
function attachPromptInput() {
|
|
375
502
|
if (settled || promptInput)
|
|
@@ -380,6 +507,7 @@ export async function login(screen = 'login', options = {}) {
|
|
|
380
507
|
: undefined);
|
|
381
508
|
if (!stream)
|
|
382
509
|
return;
|
|
510
|
+
promptStream = stream;
|
|
383
511
|
promptStreamIsTTY = Boolean(stream.isTTY);
|
|
384
512
|
promptInput = createInterface({ input: stream, terminal: false });
|
|
385
513
|
promptInput.on('line', handlePastedLine);
|
|
@@ -412,7 +540,7 @@ export async function login(screen = 'login', options = {}) {
|
|
|
412
540
|
fail(err);
|
|
413
541
|
}
|
|
414
542
|
});
|
|
415
|
-
|
|
543
|
+
async function onListening() {
|
|
416
544
|
if (settled)
|
|
417
545
|
return;
|
|
418
546
|
const address = server.address();
|
|
@@ -422,7 +550,11 @@ export async function login(screen = 'login', options = {}) {
|
|
|
422
550
|
// Resolve the auth URL with the same profile/env context so `--profile
|
|
423
551
|
// staging` opens the staging flow (not whatever the active profile is)
|
|
424
552
|
// and the minted session matches where it will be stored.
|
|
425
|
-
|
|
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`;
|
|
426
558
|
attachPromptInput();
|
|
427
559
|
try {
|
|
428
560
|
await (options.openUrl ?? openAuthUrl)(authUrl);
|
|
@@ -432,21 +564,37 @@ export async function login(screen = 'login', options = {}) {
|
|
|
432
564
|
}
|
|
433
565
|
console.error(` Open this URL in your browser if it did not open automatically:\n ${authUrl}`);
|
|
434
566
|
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
|
-
}
|
|
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');
|
|
437
575
|
process.once('SIGINT', handleSigint);
|
|
438
576
|
process.once('SIGTERM', handleSigterm);
|
|
439
577
|
});
|
|
440
578
|
}
|
|
441
579
|
// --- Authenticated Fetch ---
|
|
442
580
|
export async function authFetch(path, options = {}, authOptions = {}) {
|
|
443
|
-
const
|
|
444
|
-
if (!
|
|
445
|
-
|
|
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.', {
|
|
446
590
|
reason: 'no_stored_credentials',
|
|
447
|
-
login_command: ['moda', '
|
|
591
|
+
login_command: ['moda', 'init'],
|
|
448
592
|
});
|
|
449
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);
|
|
450
598
|
const headers = new Headers(options.headers);
|
|
451
599
|
headers.set('Authorization', `Bearer ${token}`);
|
|
452
600
|
if (!headers.has('Content-Type') && options.body) {
|
|
@@ -468,6 +616,14 @@ export async function authFetch(path, options = {}, authOptions = {}) {
|
|
|
468
616
|
for (let hop = 0;; hop++) {
|
|
469
617
|
const response = await fetch(url, { ...options, headers, redirect: 'manual' });
|
|
470
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
|
+
}
|
|
471
627
|
return response;
|
|
472
628
|
}
|
|
473
629
|
const location = response.headers.get('location');
|