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
JavaScript
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,
};