spaps 0.8.2 → 0.9.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.
@@ -0,0 +1,653 @@
1
+ const crypto = require('node:crypto');
2
+
3
+ const axios = require('axios');
4
+
5
+ const { DEFAULT_PORT } = require('../config');
6
+ const { resolveAuthApiKey } = require('./api-key');
7
+ const { resolveServerUrl } = require('./client');
8
+ const { buildApiUrl, extractApiError, unwrapApiData } = require('./http');
9
+
10
+ const REQUEST_TIMEOUT_MS = 5000;
11
+ const OIDC_DISCOVERY_PATH = '/.well-known/openid-configuration';
12
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
13
+
14
+ class AuthSurfaceError extends Error {
15
+ constructor(message, { code = 'AUTH_SURFACE_ERROR', status = null, details = null } = {}) {
16
+ super(message);
17
+ this.name = 'AuthSurfaceError';
18
+ this.code = code;
19
+ this.status = status;
20
+ this.details = details;
21
+ }
22
+ }
23
+
24
+ function resolveDiagnosticOrigin(options = {}) {
25
+ const env = options.env || process.env;
26
+ const origin = options.origin || env.SPAPS_ORIGIN || null;
27
+ return origin ? String(origin).trim().replace(/\/+$/, '') : null;
28
+ }
29
+
30
+ function isLocalServerUrl(serverUrl) {
31
+ try {
32
+ const parsed = new URL(serverUrl);
33
+ return LOCAL_HOSTS.has(parsed.hostname);
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function normalizeOrigin(origin) {
40
+ if (!origin) return null;
41
+ try {
42
+ const parsed = new URL(String(origin).trim());
43
+ return parsed.origin;
44
+ } catch {
45
+ return String(origin).trim().replace(/\/+$/, '');
46
+ }
47
+ }
48
+
49
+ function buildDiagnosticHeaders({
50
+ headers = {},
51
+ origin = null,
52
+ bearerToken = null,
53
+ hasBody = false,
54
+ cwd = process.cwd(),
55
+ env = process.env,
56
+ } = {}) {
57
+ const resolvedKey = resolveAuthApiKey({ cwd, env });
58
+ const out = {
59
+ Accept: 'application/json',
60
+ ...headers,
61
+ };
62
+
63
+ if (hasBody && !out['Content-Type'] && !out['content-type']) {
64
+ out['Content-Type'] = 'application/json';
65
+ }
66
+
67
+ if (origin && !out.Origin && !out.origin) {
68
+ out.Origin = origin;
69
+ }
70
+
71
+ if (
72
+ resolvedKey.apiKey &&
73
+ !Object.prototype.hasOwnProperty.call(out, 'X-API-Key') &&
74
+ !Object.prototype.hasOwnProperty.call(out, 'x-api-key')
75
+ ) {
76
+ out['X-API-Key'] = resolvedKey.apiKey;
77
+ }
78
+
79
+ if (bearerToken && !out.Authorization && !out.authorization) {
80
+ out.Authorization = `Bearer ${bearerToken}`;
81
+ }
82
+
83
+ return { headers: out, apiKeySource: resolvedKey.source || null };
84
+ }
85
+
86
+ async function requestJson({
87
+ serverUrl,
88
+ path,
89
+ method = 'GET',
90
+ body = null,
91
+ bearerToken = null,
92
+ origin = null,
93
+ headers = {},
94
+ cwd = process.cwd(),
95
+ env = process.env,
96
+ axiosInstance = axios,
97
+ timeoutMs = REQUEST_TIMEOUT_MS,
98
+ } = {}) {
99
+ const hasBody = body !== null && body !== undefined;
100
+ const built = buildDiagnosticHeaders({
101
+ headers,
102
+ origin,
103
+ bearerToken,
104
+ hasBody,
105
+ cwd,
106
+ env,
107
+ });
108
+
109
+ let response;
110
+ try {
111
+ response = await axiosInstance({
112
+ url: buildApiUrl(serverUrl, path),
113
+ method,
114
+ data: body,
115
+ headers: built.headers,
116
+ timeout: timeoutMs,
117
+ validateStatus: () => true,
118
+ });
119
+ } catch (err) {
120
+ throw new AuthSurfaceError(err.message || 'Request failed', {
121
+ code: err.code || 'REQUEST_FAILED',
122
+ details: { path, method },
123
+ });
124
+ }
125
+
126
+ const status = response.status || 0;
127
+ const data = unwrapApiData(response.data);
128
+ if (status < 200 || status >= 300) {
129
+ const apiError = extractApiError(response.data || {}, status);
130
+ throw new AuthSurfaceError(apiError.message, {
131
+ code: apiError.code || `HTTP_${status}`,
132
+ status,
133
+ details: { path, method, data },
134
+ });
135
+ }
136
+
137
+ return {
138
+ status,
139
+ data,
140
+ raw: response.data,
141
+ headers: response.headers || {},
142
+ apiKeySource: built.apiKeySource,
143
+ };
144
+ }
145
+
146
+ async function fetchAuthMethods({
147
+ port = DEFAULT_PORT,
148
+ serverUrl = null,
149
+ origin = null,
150
+ cwd = process.cwd(),
151
+ env = process.env,
152
+ axiosInstance = axios,
153
+ timeoutMs = REQUEST_TIMEOUT_MS,
154
+ } = {}) {
155
+ const resolvedServerUrl = resolveServerUrl({ port, serverUrl });
156
+ const resolvedOrigin = resolveDiagnosticOrigin({ origin, env });
157
+ const response = await requestJson({
158
+ serverUrl: resolvedServerUrl,
159
+ path: '/auth/methods',
160
+ method: 'GET',
161
+ origin: resolvedOrigin,
162
+ cwd,
163
+ env,
164
+ axiosInstance,
165
+ timeoutMs,
166
+ });
167
+
168
+ const methods = response.data && Array.isArray(response.data.methods)
169
+ ? response.data.methods
170
+ : [];
171
+
172
+ return {
173
+ serverUrl: resolvedServerUrl,
174
+ origin: resolvedOrigin,
175
+ status: response.status,
176
+ apiKeySource: response.apiKeySource,
177
+ methods,
178
+ raw: response.raw,
179
+ };
180
+ }
181
+
182
+ async function probeOidcIssuer(issuer, { axiosInstance = axios, timeoutMs = 2000 } = {}) {
183
+ const base = String(issuer || '').trim().replace(/\/+$/, '');
184
+ if (!base) {
185
+ return { ok: false, url: null, status: null, error: 'missing issuer' };
186
+ }
187
+ const url = `${base}${OIDC_DISCOVERY_PATH}`;
188
+ try {
189
+ const response = await axiosInstance({
190
+ url,
191
+ method: 'HEAD',
192
+ timeout: timeoutMs,
193
+ validateStatus: () => true,
194
+ });
195
+ const status = response.status || 0;
196
+ return { ok: status >= 200 && status < 400, url, status, error: null };
197
+ } catch (err) {
198
+ return { ok: false, url, status: null, error: err.message || String(err) };
199
+ }
200
+ }
201
+
202
+ function sanitizeMethod(method) {
203
+ return {
204
+ method: String(method.method || ''),
205
+ enabled: Boolean(method.enabled),
206
+ config: method.config && typeof method.config === 'object' ? { ...method.config } : {},
207
+ };
208
+ }
209
+
210
+ function isNonDevelopmentRuntime(runtime) {
211
+ const localActive = runtime && runtime.local_mode
212
+ ? Boolean(runtime.local_mode.active)
213
+ : false;
214
+ const env = String(runtime?.local_mode?.environment || '').trim().toLowerCase();
215
+ return !localActive && !['local', 'dev', 'development', 'test'].includes(env);
216
+ }
217
+
218
+ async function buildAuthDoctorChecks({
219
+ methods = [],
220
+ runtime = null,
221
+ origin = null,
222
+ probeOidcIssuer: issuerProbe = probeOidcIssuer,
223
+ axiosInstance = axios,
224
+ } = {}) {
225
+ const sanitized = Array.isArray(methods) ? methods.map(sanitizeMethod) : [];
226
+ const enabled = sanitized.filter((method) => method.enabled).map((method) => method.method);
227
+ const disabled = sanitized.filter((method) => !method.enabled).map((method) => method.method);
228
+ const checks = [
229
+ {
230
+ check: 'auth_methods',
231
+ success: Array.isArray(methods),
232
+ details: {
233
+ method_count: sanitized.length,
234
+ enabled,
235
+ disabled,
236
+ methods: sanitized,
237
+ },
238
+ fix: Array.isArray(methods) ? null : 'GET /api/auth/methods did not return a methods array.',
239
+ },
240
+ ];
241
+
242
+ const oidcMethods = sanitized.filter(
243
+ (method) => method.enabled && method.method.startsWith('oidc:')
244
+ );
245
+ const oidcIssuerResults = [];
246
+ for (const method of oidcMethods) {
247
+ const issuer = typeof method.config.issuer === 'string' ? method.config.issuer.trim() : '';
248
+ if (!issuer) {
249
+ oidcIssuerResults.push({
250
+ method: method.method,
251
+ issuer: null,
252
+ ok: false,
253
+ error: 'missing issuer',
254
+ });
255
+ continue;
256
+ }
257
+ const probe = await issuerProbe(issuer, { axiosInstance });
258
+ oidcIssuerResults.push({
259
+ method: method.method,
260
+ issuer,
261
+ ok: Boolean(probe.ok),
262
+ discovery_url: probe.url || `${issuer.replace(/\/+$/, '')}${OIDC_DISCOVERY_PATH}`,
263
+ status: probe.status || null,
264
+ error: probe.error || null,
265
+ });
266
+ }
267
+ const oidcOk = oidcIssuerResults.every((entry) => entry.ok);
268
+ checks.push({
269
+ check: 'auth_oidc_issuers',
270
+ success: oidcOk,
271
+ details: {
272
+ enabled_providers: oidcMethods.map((method) => method.method),
273
+ issuers: oidcIssuerResults,
274
+ },
275
+ fix: oidcOk
276
+ ? null
277
+ : 'Set each enabled OIDC issuer in auth_provider_configs to a reachable provider; HEAD <issuer>/.well-known/openid-configuration must return 2xx/3xx.',
278
+ });
279
+
280
+ const webauthn = sanitized.find((method) => method.method === 'webauthn');
281
+ const configuredOrigin = normalizeOrigin(webauthn?.config?.origin || null);
282
+ const requestedOrigin = normalizeOrigin(origin);
283
+ const webauthnOk = !webauthn?.enabled || Boolean(configuredOrigin && requestedOrigin && configuredOrigin === requestedOrigin);
284
+ checks.push({
285
+ check: 'auth_webauthn_origin',
286
+ success: webauthnOk,
287
+ details: {
288
+ enabled: Boolean(webauthn?.enabled),
289
+ configured_origin: configuredOrigin,
290
+ requested_origin: requestedOrigin,
291
+ relying_party_id: webauthn?.config?.relying_party_id || null,
292
+ },
293
+ fix: webauthnOk
294
+ ? null
295
+ : 'Set SPAPS_ORIGIN or --origin to the browser origin and align the WebAuthn auth_provider_configs audience with that origin.',
296
+ });
297
+
298
+ const sms = sanitized.find((method) => method.method === 'sms');
299
+ const smsProvider = String(sms?.config?.provider || '').trim().toLowerCase();
300
+ const smsConsoleInNonDev = sms?.enabled && smsProvider === 'console' && isNonDevelopmentRuntime(runtime);
301
+ const smsDisabled = Boolean(sms && !sms.enabled);
302
+ const smsOk = !smsConsoleInNonDev && !smsDisabled;
303
+ checks.push({
304
+ check: 'auth_sms_provider',
305
+ success: smsOk,
306
+ details: {
307
+ enabled: Boolean(sms?.enabled),
308
+ provider: smsProvider || null,
309
+ environment: runtime?.local_mode?.environment || null,
310
+ local_mode_active: runtime?.local_mode?.active ?? null,
311
+ },
312
+ fix: smsOk
313
+ ? null
314
+ : smsConsoleInNonDev
315
+ ? 'Do not run console SMS outside local/dev/test. Configure Twilio credentials or disable SMS.'
316
+ : 'SMS is exposed in /api/auth/methods but the configured provider is not ready.',
317
+ });
318
+
319
+ return checks;
320
+ }
321
+
322
+ function requireLocalServer(serverUrl, allowRemote, command) {
323
+ if (!allowRemote && !isLocalServerUrl(serverUrl)) {
324
+ throw new AuthSurfaceError(
325
+ `${command} refuses non-local server URLs unless --allow-remote is set.`,
326
+ { code: 'REMOTE_REFUSED' }
327
+ );
328
+ }
329
+ }
330
+
331
+ function requireValue(value, message, code = 'MISSING_ARGUMENT') {
332
+ if (!value) {
333
+ throw new AuthSurfaceError(message, { code });
334
+ }
335
+ }
336
+
337
+ function base32ToBuffer(secret) {
338
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
339
+ const cleaned = String(secret || '').toUpperCase().replace(/[\s=]+/g, '');
340
+ let bits = 0;
341
+ let value = 0;
342
+ const bytes = [];
343
+
344
+ for (const char of cleaned) {
345
+ const index = alphabet.indexOf(char);
346
+ if (index < 0) {
347
+ throw new AuthSurfaceError('Invalid TOTP secret encoding', { code: 'INVALID_TOTP_SECRET' });
348
+ }
349
+ value = (value << 5) | index;
350
+ bits += 5;
351
+ if (bits >= 8) {
352
+ bytes.push((value >>> (bits - 8)) & 0xff);
353
+ bits -= 8;
354
+ }
355
+ }
356
+
357
+ return Buffer.from(bytes);
358
+ }
359
+
360
+ function generateTotpCode(secret, { timeMs = Date.now(), period = 30, digits = 6 } = {}) {
361
+ const counter = Math.floor(Math.floor(timeMs / 1000) / period);
362
+ const buffer = Buffer.alloc(8);
363
+ buffer.writeBigUInt64BE(BigInt(counter));
364
+ const hmac = crypto.createHmac('sha1', base32ToBuffer(secret)).update(buffer).digest();
365
+ const offset = hmac[hmac.length - 1] & 0x0f;
366
+ const binary = (
367
+ ((hmac[offset] & 0x7f) << 24) |
368
+ ((hmac[offset + 1] & 0xff) << 16) |
369
+ ((hmac[offset + 2] & 0xff) << 8) |
370
+ (hmac[offset + 3] & 0xff)
371
+ );
372
+ return String(binary % (10 ** digits)).padStart(digits, '0');
373
+ }
374
+
375
+ function secretFromEnrollment(enrollment) {
376
+ if (enrollment?.secret) return enrollment.secret;
377
+ const uri = enrollment?.provisioning_uri;
378
+ if (!uri) return null;
379
+ try {
380
+ const parsed = new URL(uri);
381
+ return parsed.searchParams.get('secret');
382
+ } catch {
383
+ return null;
384
+ }
385
+ }
386
+
387
+ async function loginForTokens({
388
+ serverUrl,
389
+ email,
390
+ password,
391
+ origin = null,
392
+ cwd = process.cwd(),
393
+ env = process.env,
394
+ axiosInstance = axios,
395
+ } = {}) {
396
+ return requestJson({
397
+ serverUrl,
398
+ path: '/auth/login',
399
+ method: 'POST',
400
+ body: { email, password },
401
+ origin,
402
+ cwd,
403
+ env,
404
+ axiosInstance,
405
+ });
406
+ }
407
+
408
+ async function runMfaTest({
409
+ port = DEFAULT_PORT,
410
+ serverUrl = null,
411
+ origin = null,
412
+ email = null,
413
+ password = null,
414
+ allowRemote = false,
415
+ cwd = process.cwd(),
416
+ env = process.env,
417
+ axiosInstance = axios,
418
+ nowMs = Date.now,
419
+ } = {}) {
420
+ const resolvedServerUrl = resolveServerUrl({ port, serverUrl });
421
+ const resolvedOrigin = resolveDiagnosticOrigin({ origin, env });
422
+ const resolvedEmail = email || env.SPAPS_TEST_EMAIL || null;
423
+ const resolvedPassword = password || env.SPAPS_TEST_PASSWORD || null;
424
+ requireLocalServer(resolvedServerUrl, allowRemote, 'spaps auth mfa-test');
425
+ requireValue(resolvedEmail, 'Pass --email or set SPAPS_TEST_EMAIL for mfa-test.');
426
+ requireValue(resolvedPassword, 'Pass --password or set SPAPS_TEST_PASSWORD for mfa-test.');
427
+
428
+ const steps = [];
429
+ const initialLogin = await loginForTokens({
430
+ serverUrl: resolvedServerUrl,
431
+ email: resolvedEmail,
432
+ password: resolvedPassword,
433
+ origin: resolvedOrigin,
434
+ cwd,
435
+ env,
436
+ axiosInstance,
437
+ });
438
+ if (initialLogin.data?.mfa_required) {
439
+ throw new AuthSurfaceError(
440
+ 'The supplied user already requires MFA; use a non-MFA local test user for enrollment smoke tests.',
441
+ { code: 'USER_ALREADY_REQUIRES_MFA' }
442
+ );
443
+ }
444
+ const initialAccessToken = initialLogin.data?.access_token;
445
+ requireValue(initialAccessToken, 'Login did not return an access token.', 'AUTH_TEST_FAILED');
446
+ steps.push({ name: 'login', success: true });
447
+
448
+ const enrollment = await requestJson({
449
+ serverUrl: resolvedServerUrl,
450
+ path: '/auth/mfa/totp/enroll',
451
+ method: 'POST',
452
+ bearerToken: initialAccessToken,
453
+ origin: resolvedOrigin,
454
+ cwd,
455
+ env,
456
+ axiosInstance,
457
+ });
458
+ const secret = secretFromEnrollment(enrollment.data);
459
+ requireValue(secret, 'TOTP enrollment did not return a provisioning secret.', 'AUTH_TEST_FAILED');
460
+ steps.push({ name: 'enroll', success: true });
461
+
462
+ const activateCode = generateTotpCode(secret, { timeMs: nowMs() });
463
+ const activation = await requestJson({
464
+ serverUrl: resolvedServerUrl,
465
+ path: '/auth/mfa/totp/activate',
466
+ method: 'POST',
467
+ body: { code: activateCode },
468
+ bearerToken: initialAccessToken,
469
+ origin: resolvedOrigin,
470
+ cwd,
471
+ env,
472
+ axiosInstance,
473
+ });
474
+ const recoveryCode = Array.isArray(activation.data?.recovery_codes)
475
+ ? activation.data.recovery_codes[0]
476
+ : null;
477
+ steps.push({ name: 'activate', success: true });
478
+
479
+ const mfaLogin = await loginForTokens({
480
+ serverUrl: resolvedServerUrl,
481
+ email: resolvedEmail,
482
+ password: resolvedPassword,
483
+ origin: resolvedOrigin,
484
+ cwd,
485
+ env,
486
+ axiosInstance,
487
+ });
488
+ if (!mfaLogin.data?.mfa_required) {
489
+ throw new AuthSurfaceError('Second login did not return mfa_required after activation.', {
490
+ code: 'MFA_NOT_REQUIRED',
491
+ });
492
+ }
493
+ steps.push({ name: 'login_mfa', success: true });
494
+
495
+ const verifyCode = generateTotpCode(secret, { timeMs: nowMs() + 30000 });
496
+ const verified = await requestJson({
497
+ serverUrl: resolvedServerUrl,
498
+ path: '/auth/mfa/verify',
499
+ method: 'POST',
500
+ body: {
501
+ challenge_id: mfaLogin.data.challenge_id,
502
+ challenge: mfaLogin.data.challenge,
503
+ code: verifyCode,
504
+ },
505
+ origin: resolvedOrigin,
506
+ cwd,
507
+ env,
508
+ axiosInstance,
509
+ });
510
+ const verifiedAccessToken = verified.data?.access_token;
511
+ requireValue(verifiedAccessToken, 'MFA verification did not return an access token.', 'AUTH_TEST_FAILED');
512
+ steps.push({ name: 'verify', success: true });
513
+
514
+ if (recoveryCode) {
515
+ await requestJson({
516
+ serverUrl: resolvedServerUrl,
517
+ path: '/auth/mfa/totp/disable',
518
+ method: 'POST',
519
+ body: { code: recoveryCode },
520
+ bearerToken: verifiedAccessToken,
521
+ origin: resolvedOrigin,
522
+ cwd,
523
+ env,
524
+ axiosInstance,
525
+ });
526
+ steps.push({ name: 'cleanup', success: true });
527
+ } else {
528
+ steps.push({ name: 'cleanup', success: false, skipped: true, reason: 'no recovery code returned' });
529
+ }
530
+
531
+ return {
532
+ success: true,
533
+ command: 'auth.mfa-test',
534
+ server_url: resolvedServerUrl,
535
+ origin: resolvedOrigin,
536
+ user: {
537
+ id: verified.data?.user?.id || initialLogin.data?.user?.id || null,
538
+ email: verified.data?.user?.email || initialLogin.data?.user?.email || resolvedEmail,
539
+ },
540
+ steps,
541
+ };
542
+ }
543
+
544
+ function assertConsoleSms(methods) {
545
+ const sms = Array.isArray(methods)
546
+ ? methods.find((method) => method && method.method === 'sms')
547
+ : null;
548
+ if (!sms || !sms.enabled || sms.config?.provider !== 'console') {
549
+ throw new AuthSurfaceError(
550
+ 'sms-test only runs when /api/auth/methods reports enabled console SMS.',
551
+ { code: 'SMS_CONSOLE_REQUIRED', details: { sms: sms || null } }
552
+ );
553
+ }
554
+ }
555
+
556
+ async function runSmsTest({
557
+ port = DEFAULT_PORT,
558
+ serverUrl = null,
559
+ origin = null,
560
+ phoneNumber = null,
561
+ challengeId = null,
562
+ code = null,
563
+ allowRemote = false,
564
+ cwd = process.cwd(),
565
+ env = process.env,
566
+ axiosInstance = axios,
567
+ } = {}) {
568
+ const resolvedServerUrl = resolveServerUrl({ port, serverUrl });
569
+ const resolvedOrigin = resolveDiagnosticOrigin({ origin, env });
570
+ const resolvedPhoneNumber = phoneNumber || env.SPAPS_TEST_PHONE_NUMBER || null;
571
+ requireLocalServer(resolvedServerUrl, allowRemote, 'spaps auth sms-test');
572
+ requireValue(resolvedPhoneNumber, 'Pass --phone-number or set SPAPS_TEST_PHONE_NUMBER for sms-test.');
573
+
574
+ const discovery = await fetchAuthMethods({
575
+ serverUrl: resolvedServerUrl,
576
+ origin: resolvedOrigin,
577
+ cwd,
578
+ env,
579
+ axiosInstance,
580
+ });
581
+ assertConsoleSms(discovery.methods);
582
+
583
+ if (challengeId && code) {
584
+ const verified = await requestJson({
585
+ serverUrl: resolvedServerUrl,
586
+ path: '/auth/sms/verify',
587
+ method: 'POST',
588
+ body: {
589
+ phone_number: resolvedPhoneNumber,
590
+ challenge_id: challengeId,
591
+ code,
592
+ },
593
+ origin: resolvedOrigin,
594
+ cwd,
595
+ env,
596
+ axiosInstance,
597
+ });
598
+ return {
599
+ success: true,
600
+ command: 'auth.sms-test',
601
+ server_url: resolvedServerUrl,
602
+ origin: resolvedOrigin,
603
+ verified: true,
604
+ user: {
605
+ id: verified.data?.user?.id || null,
606
+ phone_number: verified.data?.user?.phone_number || resolvedPhoneNumber,
607
+ },
608
+ };
609
+ }
610
+
611
+ if (challengeId || code) {
612
+ throw new AuthSurfaceError(
613
+ 'Pass both --challenge-id and --code to verify an existing SMS challenge.',
614
+ { code: 'SMS_VERIFY_ARGUMENTS_REQUIRED' }
615
+ );
616
+ }
617
+
618
+ const requested = await requestJson({
619
+ serverUrl: resolvedServerUrl,
620
+ path: '/auth/sms/request',
621
+ method: 'POST',
622
+ body: { phone_number: resolvedPhoneNumber },
623
+ origin: resolvedOrigin,
624
+ cwd,
625
+ env,
626
+ axiosInstance,
627
+ });
628
+
629
+ return {
630
+ success: true,
631
+ command: 'auth.sms-test',
632
+ server_url: resolvedServerUrl,
633
+ origin: resolvedOrigin,
634
+ verification_required: true,
635
+ challenge_id: requested.data?.challenge_id || null,
636
+ delivery_status: requested.data?.delivery_status || null,
637
+ message: requested.data?.message || 'Verification code sent',
638
+ next_step: 'Read the code from local server logs, then rerun with --challenge-id and --code.',
639
+ };
640
+ }
641
+
642
+ module.exports = {
643
+ AuthSurfaceError,
644
+ buildAuthDoctorChecks,
645
+ buildDiagnosticHeaders,
646
+ fetchAuthMethods,
647
+ generateTotpCode,
648
+ isLocalServerUrl,
649
+ requestJson,
650
+ resolveDiagnosticOrigin,
651
+ runMfaTest,
652
+ runSmsTest,
653
+ };