UNPKG

spaps

Version:

Sweet Potato Authentication & Payment Service CLI - Docker Compose orchestrator for local Python/FastAPI SPAPS server with built-in admin middleware

655 lines (602 loc) 18.9 kB
const crypto = require('node:crypto'); const axios = require('axios'); const { DEFAULT_PORT } = require('../config'); const { resolveAuthApiKey } = require('./api-key'); const { resolveServerUrl } = require('./client'); const { buildApiUrl, extractApiError, unwrapApiData } = require('./http'); const REQUEST_TIMEOUT_MS = 5000; const OIDC_DISCOVERY_PATH = '/.well-known/openid-configuration'; const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1']); class AuthSurfaceError extends Error { constructor(message, { code = 'AUTH_SURFACE_ERROR', status = null, details = null } = {}) { super(message); this.name = 'AuthSurfaceError'; this.code = code; this.status = status; this.details = details; } } function resolveDiagnosticOrigin(options = {}) { const env = options.env || process.env; const origin = options.origin || env.SPAPS_ORIGIN || null; return origin ? String(origin).trim().replace(/\/+$/, '') : null; } function isLocalServerUrl(serverUrl) { try { const parsed = new URL(serverUrl); return LOCAL_HOSTS.has(parsed.hostname); } catch { return false; } } function normalizeOrigin(origin) { if (!origin) return null; try { const parsed = new URL(String(origin).trim()); return parsed.origin; } catch { return String(origin).trim().replace(/\/+$/, ''); } } function buildDiagnosticHeaders({ headers = {}, origin = null, bearerToken = null, hasBody = false, cwd = process.cwd(), env = process.env, } = {}) { const resolvedKey = resolveAuthApiKey({ cwd, env }); const out = { Accept: 'application/json', ...headers, }; if (hasBody && !out['Content-Type'] && !out['content-type']) { out['Content-Type'] = 'application/json'; } if (origin && !out.Origin && !out.origin) { out.Origin = origin; } if ( resolvedKey.apiKey && !Object.prototype.hasOwnProperty.call(out, 'X-API-Key') && !Object.prototype.hasOwnProperty.call(out, 'x-api-key') ) { out['X-API-Key'] = resolvedKey.apiKey; } if (bearerToken && !out.Authorization && !out.authorization) { out.Authorization = `Bearer ${bearerToken}`; } return { headers: out, apiKeySource: resolvedKey.source || null }; } async function requestJson({ serverUrl, path, method = 'GET', body = null, bearerToken = null, origin = null, headers = {}, cwd = process.cwd(), env = process.env, axiosInstance = axios, timeoutMs = REQUEST_TIMEOUT_MS, } = {}) { const hasBody = body !== null && body !== undefined; const built = buildDiagnosticHeaders({ headers, origin, bearerToken, hasBody, cwd, env, }); let response; try { response = await axiosInstance({ url: buildApiUrl(serverUrl, path), method, data: body, headers: built.headers, timeout: timeoutMs, validateStatus: () => true, }); } catch (err) { throw new AuthSurfaceError(err.message || 'Request failed', { code: err.code || 'REQUEST_FAILED', details: { path, method }, }); } const status = response.status || 0; const data = unwrapApiData(response.data); if (status < 200 || status >= 300) { const apiError = extractApiError(response.data || {}, status); throw new AuthSurfaceError(apiError.message, { code: apiError.code || `HTTP_${status}`, status, details: { path, method, data }, }); } return { status, data, raw: response.data, headers: response.headers || {}, apiKeySource: built.apiKeySource, }; } async function fetchAuthMethods({ port = DEFAULT_PORT, serverUrl = null, origin = null, cwd = process.cwd(), env = process.env, axiosInstance = axios, timeoutMs = REQUEST_TIMEOUT_MS, } = {}) { const resolvedServerUrl = resolveServerUrl({ port, serverUrl }); const resolvedOrigin = resolveDiagnosticOrigin({ origin, env }); const response = await requestJson({ serverUrl: resolvedServerUrl, path: '/auth/methods', method: 'GET', origin: resolvedOrigin, cwd, env, axiosInstance, timeoutMs, }); const methods = response.data && Array.isArray(response.data.methods) ? response.data.methods : []; return { serverUrl: resolvedServerUrl, origin: resolvedOrigin, status: response.status, apiKeySource: response.apiKeySource, headers: response.headers, methods, raw: response.raw, }; } async function probeOidcIssuer(issuer, { axiosInstance = axios, timeoutMs = 2000 } = {}) { const base = String(issuer || '').trim().replace(/\/+$/, ''); if (!base) { return { ok: false, url: null, status: null, error: 'missing issuer' }; } const url = `${base}${OIDC_DISCOVERY_PATH}`; try { const response = await axiosInstance({ url, method: 'HEAD', timeout: timeoutMs, validateStatus: () => true, }); const status = response.status || 0; return { ok: status >= 200 && status < 400, url, status, error: null }; } catch (err) { return { ok: false, url, status: null, error: err.message || String(err) }; } } function sanitizeMethod(method) { return { method: String(method.method || ''), enabled: Boolean(method.enabled), config: method.config && typeof method.config === 'object' ? { ...method.config } : {}, }; } function isNonDevelopmentRuntime(runtime) { const localActive = runtime && runtime.local_mode ? Boolean(runtime.local_mode.active) : false; const env = String(runtime?.local_mode?.environment || '').trim().toLowerCase(); return !localActive && !['local', 'dev', 'development', 'test'].includes(env); } async function buildAuthDoctorChecks({ methods = [], runtime = null, origin = null, probeOidcIssuer: issuerProbe = probeOidcIssuer, axiosInstance = axios, } = {}) { const sanitized = Array.isArray(methods) ? methods.map(sanitizeMethod) : []; const enabled = sanitized.filter((method) => method.enabled).map((method) => method.method); const disabled = sanitized.filter((method) => !method.enabled).map((method) => method.method); const checks = [ { check: 'auth_methods', success: Array.isArray(methods), details: { method_count: sanitized.length, enabled, disabled, methods: sanitized, }, fix: Array.isArray(methods) ? null : 'GET /api/auth/methods did not return a methods array.', }, ]; const oidcMethods = sanitized.filter( (method) => method.enabled && method.method.startsWith('oidc:') ); const oidcIssuerResults = []; for (const method of oidcMethods) { const issuer = typeof method.config.issuer === 'string' ? method.config.issuer.trim() : ''; if (!issuer) { oidcIssuerResults.push({ method: method.method, issuer: null, ok: false, error: 'missing issuer', }); continue; } const probe = await issuerProbe(issuer, { axiosInstance }); oidcIssuerResults.push({ method: method.method, issuer, ok: Boolean(probe.ok), discovery_url: probe.url || `${issuer.replace(/\/+$/, '')}${OIDC_DISCOVERY_PATH}`, status: probe.status || null, error: probe.error || null, }); } const oidcOk = oidcIssuerResults.every((entry) => entry.ok); checks.push({ check: 'auth_oidc_issuers', success: oidcOk, details: { enabled_providers: oidcMethods.map((method) => method.method), issuers: oidcIssuerResults, }, fix: oidcOk ? null : 'Set each enabled OIDC issuer in auth_provider_configs to a reachable provider; HEAD <issuer>/.well-known/openid-configuration must return 2xx/3xx.', }); const webauthn = sanitized.find((method) => method.method === 'webauthn'); const configuredOrigin = normalizeOrigin(webauthn?.config?.origin || null); const requestedOrigin = normalizeOrigin(origin); const webauthnOk = !webauthn?.enabled || Boolean(configuredOrigin && requestedOrigin && configuredOrigin === requestedOrigin); checks.push({ check: 'auth_webauthn_origin', success: webauthnOk, details: { enabled: Boolean(webauthn?.enabled), configured_origin: configuredOrigin, requested_origin: requestedOrigin, relying_party_id: webauthn?.config?.relying_party_id || null, }, fix: webauthnOk ? null : 'Set SPAPS_ORIGIN or --origin to the browser origin and align the WebAuthn auth_provider_configs audience with that origin.', }); const sms = sanitized.find((method) => method.method === 'sms'); const smsProvider = String(sms?.config?.provider || '').trim().toLowerCase(); const smsConsoleInNonDev = sms?.enabled && smsProvider === 'console' && isNonDevelopmentRuntime(runtime); const smsDisabled = Boolean(sms && !sms.enabled); const smsOk = !smsConsoleInNonDev && !smsDisabled; checks.push({ check: 'auth_sms_provider', success: smsOk, details: { enabled: Boolean(sms?.enabled), provider: smsProvider || null, environment: runtime?.local_mode?.environment || null, local_mode_active: runtime?.local_mode?.active ?? null, }, fix: smsOk ? null : smsConsoleInNonDev ? 'Do not run console SMS outside local/dev/test. Configure Twilio credentials or disable SMS.' : 'SMS is exposed in /api/auth/methods but the configured provider is not ready.', }); return checks; } function requireLocalServer(serverUrl, allowRemote, command) { if (!allowRemote && !isLocalServerUrl(serverUrl)) { throw new AuthSurfaceError( `${command} refuses non-local server URLs unless --allow-remote is set.`, { code: 'REMOTE_REFUSED' } ); } } function requireValue(value, message, code = 'MISSING_ARGUMENT') { if (!value) { throw new AuthSurfaceError(message, { code }); } } function base32ToBuffer(secret) { const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; const cleaned = String(secret || '').toUpperCase().replace(/[\s=]+/g, ''); let bits = 0; let value = 0; const bytes = []; for (const char of cleaned) { const index = alphabet.indexOf(char); if (index < 0) { throw new AuthSurfaceError('Invalid TOTP secret encoding', { code: 'INVALID_TOTP_SECRET' }); } value = (value << 5) | index; bits += 5; if (bits >= 8) { bytes.push((value >>> (bits - 8)) & 0xff); bits -= 8; } } return Buffer.from(bytes); } function generateTotpCode(secret, { timeMs = Date.now(), period = 30, digits = 6 } = {}) { const counter = Math.floor(Math.floor(timeMs / 1000) / period); const buffer = Buffer.alloc(8); buffer.writeBigUInt64BE(BigInt(counter)); const hmac = crypto.createHmac('sha1', base32ToBuffer(secret)).update(buffer).digest(); const offset = hmac[hmac.length - 1] & 0x0f; const binary = ( ((hmac[offset] & 0x7f) << 24) | ((hmac[offset + 1] & 0xff) << 16) | ((hmac[offset + 2] & 0xff) << 8) | (hmac[offset + 3] & 0xff) ); return String(binary % (10 ** digits)).padStart(digits, '0'); } function secretFromEnrollment(enrollment) { if (enrollment?.secret) return enrollment.secret; const uri = enrollment?.provisioning_uri; if (!uri) return null; try { const parsed = new URL(uri); return parsed.searchParams.get('secret'); } catch { return null; } } async function loginForTokens({ serverUrl, email, password, origin = null, cwd = process.cwd(), env = process.env, axiosInstance = axios, } = {}) { return requestJson({ serverUrl, path: '/auth/login', method: 'POST', body: { email, password }, origin, cwd, env, axiosInstance, }); } async function runMfaTest({ port = DEFAULT_PORT, serverUrl = null, origin = null, email = null, password = null, allowRemote = false, cwd = process.cwd(), env = process.env, axiosInstance = axios, nowMs = Date.now, } = {}) { const resolvedServerUrl = resolveServerUrl({ port, serverUrl }); const resolvedOrigin = resolveDiagnosticOrigin({ origin, env }); const resolvedEmail = email || env.SPAPS_TEST_EMAIL || null; const resolvedPassword = password || env.SPAPS_TEST_PASSWORD || null; requireLocalServer(resolvedServerUrl, allowRemote, 'spaps auth mfa-test'); requireValue(resolvedEmail, 'Pass --email or set SPAPS_TEST_EMAIL for mfa-test.'); requireValue(resolvedPassword, 'Pass --password or set SPAPS_TEST_PASSWORD for mfa-test.'); const steps = []; const initialLogin = await loginForTokens({ serverUrl: resolvedServerUrl, email: resolvedEmail, password: resolvedPassword, origin: resolvedOrigin, cwd, env, axiosInstance, }); if (initialLogin.data?.mfa_required) { throw new AuthSurfaceError( 'The supplied user already requires MFA; use a non-MFA local test user for enrollment smoke tests.', { code: 'USER_ALREADY_REQUIRES_MFA' } ); } const initialAccessToken = initialLogin.data?.access_token; requireValue(initialAccessToken, 'Login did not return an access token.', 'AUTH_TEST_FAILED'); steps.push({ name: 'login', success: true }); const enrollment = await requestJson({ serverUrl: resolvedServerUrl, path: '/auth/mfa/totp/enroll', method: 'POST', bearerToken: initialAccessToken, origin: resolvedOrigin, cwd, env, axiosInstance, }); const secret = secretFromEnrollment(enrollment.data); requireValue(secret, 'TOTP enrollment did not return a provisioning secret.', 'AUTH_TEST_FAILED'); steps.push({ name: 'enroll', success: true }); const activateCode = generateTotpCode(secret, { timeMs: nowMs() }); const activation = await requestJson({ serverUrl: resolvedServerUrl, path: '/auth/mfa/totp/activate', method: 'POST', body: { code: activateCode }, bearerToken: initialAccessToken, origin: resolvedOrigin, cwd, env, axiosInstance, }); const recoveryCode = Array.isArray(activation.data?.recovery_codes) ? activation.data.recovery_codes[0] : null; steps.push({ name: 'activate', success: true }); const mfaLogin = await loginForTokens({ serverUrl: resolvedServerUrl, email: resolvedEmail, password: resolvedPassword, origin: resolvedOrigin, cwd, env, axiosInstance, }); if (!mfaLogin.data?.mfa_required) { throw new AuthSurfaceError('Second login did not return mfa_required after activation.', { code: 'MFA_NOT_REQUIRED', }); } steps.push({ name: 'login_mfa', success: true }); const verifyCode = generateTotpCode(secret, { timeMs: nowMs() + 30000 }); const verified = await requestJson({ serverUrl: resolvedServerUrl, path: '/auth/mfa/verify', method: 'POST', body: { challenge_id: mfaLogin.data.challenge_id, challenge: mfaLogin.data.challenge, code: verifyCode, }, origin: resolvedOrigin, cwd, env, axiosInstance, }); const verifiedAccessToken = verified.data?.access_token; requireValue(verifiedAccessToken, 'MFA verification did not return an access token.', 'AUTH_TEST_FAILED'); steps.push({ name: 'verify', success: true }); if (recoveryCode) { await requestJson({ serverUrl: resolvedServerUrl, path: '/auth/mfa/totp/disable', method: 'POST', body: { code: recoveryCode }, bearerToken: verifiedAccessToken, origin: resolvedOrigin, cwd, env, axiosInstance, }); steps.push({ name: 'cleanup', success: true }); } else { steps.push({ name: 'cleanup', success: false, skipped: true, reason: 'no recovery code returned' }); } return { success: true, command: 'auth.mfa-test', server_url: resolvedServerUrl, origin: resolvedOrigin, user: { id: verified.data?.user?.id || initialLogin.data?.user?.id || null, email: verified.data?.user?.email || initialLogin.data?.user?.email || resolvedEmail, }, steps, }; } function assertConsoleSms(methods) { const sms = Array.isArray(methods) ? methods.find((method) => method && method.method === 'sms') : null; if (!sms || !sms.enabled || sms.config?.provider !== 'console') { throw new AuthSurfaceError( 'sms-test only runs when /api/auth/methods reports enabled console SMS.', { code: 'SMS_CONSOLE_REQUIRED', details: { sms: sms || null } } ); } } async function runSmsTest({ port = DEFAULT_PORT, serverUrl = null, origin = null, phoneNumber = null, challengeId = null, code = null, allowRemote = false, cwd = process.cwd(), env = process.env, axiosInstance = axios, } = {}) { const resolvedServerUrl = resolveServerUrl({ port, serverUrl }); const resolvedOrigin = resolveDiagnosticOrigin({ origin, env }); const resolvedPhoneNumber = phoneNumber || env.SPAPS_TEST_PHONE_NUMBER || null; requireLocalServer(resolvedServerUrl, allowRemote, 'spaps auth sms-test'); requireValue(resolvedPhoneNumber, 'Pass --phone-number or set SPAPS_TEST_PHONE_NUMBER for sms-test.'); const discovery = await fetchAuthMethods({ serverUrl: resolvedServerUrl, origin: resolvedOrigin, cwd, env, axiosInstance, }); assertConsoleSms(discovery.methods); if (challengeId && code) { const verified = await requestJson({ serverUrl: resolvedServerUrl, path: '/auth/sms/verify', method: 'POST', body: { phone_number: resolvedPhoneNumber, challenge_id: challengeId, code, }, origin: resolvedOrigin, cwd, env, axiosInstance, }); return { success: true, command: 'auth.sms-test', server_url: resolvedServerUrl, origin: resolvedOrigin, verified: true, user: { id: verified.data?.user?.id || null, phone_number: verified.data?.user?.phone_number || resolvedPhoneNumber, }, }; } if (challengeId || code) { throw new AuthSurfaceError( 'Pass both --challenge-id and --code to verify an existing SMS challenge.', { code: 'SMS_VERIFY_ARGUMENTS_REQUIRED' } ); } const requested = await requestJson({ serverUrl: resolvedServerUrl, path: '/auth/sms/request', method: 'POST', body: { phone_number: resolvedPhoneNumber }, origin: resolvedOrigin, cwd, env, axiosInstance, }); return { success: true, command: 'auth.sms-test', server_url: resolvedServerUrl, origin: resolvedOrigin, verification_required: true, challenge_id: requested.data?.challenge_id || null, delivery_status: requested.data?.delivery_status || null, message: requested.data?.message || 'Verification code sent', next_step: 'Read the code from local server logs, then rerun with --challenge-id and --code.', }; } module.exports = { AuthSurfaceError, buildAuthDoctorChecks, buildDiagnosticHeaders, fetchAuthMethods, generateTotpCode, isLocalServerUrl, requestJson, resolveDiagnosticOrigin, runMfaTest, runSmsTest, };