UNPKG

spaps

Version:

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

666 lines (621 loc) 22.9 kB
const fs = require('node:fs'); const path = require('node:path'); const { spawnSync: defaultSpawnSync } = require('node:child_process'); const { parseEnvAssignments } = require('./api-key'); const { fetchAuthMethods } = require('./surface'); const { requestPasskeyProfile } = require('./passkeys'); const MIN_PASSKEY_SDK_VERSION = '1.14.0'; const REQUIRED_PUBLISHABLE_SCOPE = 'webauthn_ceremonies'; const LOCAL_SERVER_HOSTS = new Set(['localhost', '127.0.0.1', '::1']); const BROWSER_PASSKEY_TEMPLATES = new Set(['nextjs', 'react', 'vanilla']); class PasskeyDiagnosticError extends Error { constructor(message, { code = 'PASSKEY_DIAGNOSTIC_ERROR', recoverable = true, fixHint = null, nextActions = [], } = {}) { super(message); this.name = 'PasskeyDiagnosticError'; this.code = code; this.recoverable = recoverable; this.fix_hint = fixHint; this.next_actions = [...nextActions]; } } function normalizeOrigin(value) { if (!value) return null; try { return new URL(String(value)).origin; } catch { return null; } } function normalizeServerUrl(value) { if (!value) return null; try { return new URL(String(value)).origin; } catch { return null; } } function canonicalHostname(value) { if (!value) return null; let hostname = String(value).trim().toLowerCase().replace(/\.$/, ''); if (hostname.startsWith('[') && hostname.endsWith(']')) { hostname = hostname.slice(1, -1); } const zoneIndex = hostname.indexOf('%'); if (zoneIndex >= 0) hostname = hostname.slice(0, zoneIndex); return hostname || null; } function normalizeHostname(value) { return canonicalHostname(value); } function isLocalUrl(value) { try { return LOCAL_SERVER_HOSTS.has(canonicalHostname(new URL(value).hostname)); } catch { return false; } } function isBrowserLocalHostname(value) { const hostname = canonicalHostname(value); return hostname === 'localhost' || Boolean(hostname?.endsWith('.localhost')); } function isSecureBrowserOrigin(value) { try { const parsed = new URL(value); return parsed.protocol === 'https:' || (parsed.protocol === 'http:' && isBrowserLocalHostname(parsed.hostname)); } catch { return false; } } function parseVersion(value) { const match = String(value || '').trim().match(/^(\d+)\.(\d+)\.(\d+)/); return match ? match.slice(1).map(Number) : null; } function versionAtLeast(actual, minimum = MIN_PASSKEY_SDK_VERSION) { const left = parseVersion(actual); const right = parseVersion(minimum); if (!left || !right) return false; for (let index = 0; index < right.length; index += 1) { if (left[index] > right[index]) return true; if (left[index] < right[index]) return false; } return true; } function pass(code, summary, details = {}) { return { code, status: 'pass', summary, details, fix_hint: null }; } function fail(code, summary, fixHint, details = {}) { return { code, status: 'fail', summary, details, fix_hint: fixHint }; } function buildPasskeyDoctorReport(evidence = {}) { const server = evidence.server || {}; const discovery = evidence.discovery || {}; const profile = evidence.profile || {}; const expected = evidence.expected || {}; const browserKey = evidence.browser_key || {}; const cors = evidence.cors || {}; const sdk = evidence.sdk || {}; const scaffold = evidence.scaffold || {}; const checks = []; checks.push(server.reachable ? pass('PASSKEY_SERVER_DISCOVERY', 'SPAPS auth discovery is reachable.', { server_url: normalizeServerUrl(server.url), webauthn_enabled: Boolean(discovery.webauthn_enabled), }) : fail( 'PASSKEY_SERVER_DISCOVERY', 'SPAPS auth discovery is unavailable.', 'Start SPAPS or pass --server-url to the intended application, then rerun passkeys doctor.', { server_url: normalizeServerUrl(server.url) } )); const keyType = ['publishable', 'secret', 'missing'].includes(browserKey.type) ? browserKey.type : 'missing'; checks.push(keyType === 'publishable' ? pass('PASSKEY_BROWSER_KEY', 'Browser configuration uses a publishable key.', { key_type: keyType }) : fail( 'PASSKEY_BROWSER_KEY', keyType === 'secret' ? 'A secret SPAPS key is configured for browser use.' : 'No publishable browser key was found.', 'Use a spaps_pub_ key in the browser scaffold; keep spaps_sec_ keys server-side only.', { key_type: keyType } )); const scopes = Array.isArray(browserKey.scopes) ? [...new Set(browserKey.scopes.map(String))].sort() : []; checks.push(scopes.includes(REQUIRED_PUBLISHABLE_SCOPE) ? pass('PASSKEY_PUBLISHABLE_SCOPE', 'Publishable ceremony scope is present.', { required_scope: REQUIRED_PUBLISHABLE_SCOPE, scope_evidence_count: scopes.length, }) : fail( 'PASSKEY_PUBLISHABLE_SCOPE', 'Publishable ceremony scope is missing or unproven.', 'Grant the publishable key the webauthn_ceremonies scope, then rerun passkeys doctor.', { required_scope: REQUIRED_PUBLISHABLE_SCOPE, scope_evidence_count: scopes.length } )); const expectedRp = normalizeHostname(expected.rp_id); const expectedOrigin = normalizeOrigin(expected.origin); const discoveredRp = normalizeHostname(discovery.relying_party_id); const discoveredOrigin = normalizeOrigin(discovery.origin); const profileRp = normalizeHostname(profile.rp_id); const profileOrigins = Array.isArray(profile.allowed_origins) ? profile.allowed_origins.map(normalizeOrigin).filter(Boolean).sort() : []; const rpOriginMatch = Boolean( discovery.webauthn_enabled && profile.enabled !== false && expectedRp && expectedOrigin && discoveredRp === expectedRp && discoveredOrigin === expectedOrigin && profileRp === expectedRp && profileOrigins.includes(expectedOrigin) ); checks.push(rpOriginMatch ? pass('PASSKEY_RP_ORIGIN', 'Discovery and profile match the exact RP/origin.', { expected_rp_id: expectedRp, expected_origin: expectedOrigin, }) : fail( 'PASSKEY_RP_ORIGIN', 'Discovery, profile, and expected RP/origin do not match exactly.', 'Run spaps auth passkeys show/validate and align --rp-id, --origin, and allowed_origins.', { expected_rp_id: expectedRp, expected_origin: expectedOrigin, discovered_rp_id: discoveredRp, discovered_origin: discoveredOrigin, profile_rp_id: profileRp, profile_origins: profileOrigins, } )); const expectedEnvironment = expected.environment ? String(expected.environment).trim().toLowerCase() : null; const profileEnvironment = profile.environment ? String(profile.environment).trim().toLowerCase() : null; checks.push(Boolean(expectedEnvironment && profileEnvironment === expectedEnvironment) ? pass('PASSKEY_PROFILE_ENVIRONMENT', 'Passkey profile environment matches the target.', { environment: expectedEnvironment, }) : fail( 'PASSKEY_PROFILE_ENVIRONMENT', 'Passkey profile environment does not match the target environment.', 'Validate the preview/production profile explicitly before changing it.', { expected_environment: expectedEnvironment, profile_environment: profileEnvironment } )); checks.push(isSecureBrowserOrigin(expectedOrigin) ? pass('PASSKEY_SECURE_CONTEXT', 'Browser origin is a secure WebAuthn context.', { origin: expectedOrigin, localhost_exception: expectedOrigin ? new URL(expectedOrigin).protocol === 'http:' && isBrowserLocalHostname(new URL(expectedOrigin).hostname) : false, }) : fail( 'PASSKEY_SECURE_CONTEXT', 'Browser origin is not HTTPS or a localhost secure-context exception.', 'Use HTTPS for preview/production; plain HTTP is supported only on localhost.', { origin: expectedOrigin } )); const corsOrigins = Array.isArray(cors.allowed_origins) ? cors.allowed_origins.map(normalizeOrigin).filter(Boolean).sort() : []; checks.push(Boolean(expectedOrigin && corsOrigins.includes(expectedOrigin)) ? pass('PASSKEY_CORS', 'CORS evidence includes the exact browser origin.', { origin: expectedOrigin, evidence_count: corsOrigins.length, }) : fail( 'PASSKEY_CORS', 'CORS evidence does not include the exact browser origin.', 'Add the exact origin to CORS_ALLOW_ORIGINS and confirm the discovery response header.', { origin: expectedOrigin, evidence_count: corsOrigins.length } )); checks.push(Boolean(sdk.installed && versionAtLeast(sdk.version)) ? pass('PASSKEY_SDK_VERSION', 'Installed spaps-sdk includes high-level passkey ceremonies.', { installed_version: String(sdk.version), minimum_version: MIN_PASSKEY_SDK_VERSION, }) : fail( 'PASSKEY_SDK_VERSION', 'Installed spaps-sdk is missing or too old for the supported passkey path.', `Install spaps-sdk >= ${MIN_PASSKEY_SDK_VERSION} and use client.auth.passkeys.`, { installed: Boolean(sdk.installed), installed_version: sdk.version ? String(sdk.version) : null, minimum_version: MIN_PASSKEY_SDK_VERSION, } )); const scaffoldEnvironment = scaffold.environment ? String(scaffold.environment).trim().toLowerCase() : null; const scaffoldOrigins = Array.isArray(scaffold.allowed_origins) ? scaffold.allowed_origins.map(normalizeOrigin).filter(Boolean).sort() : []; const scaffoldTemplate = scaffold.template ? String(scaffold.template).toLowerCase() : null; const scaffoldMatches = Boolean( scaffold.found && BROWSER_PASSKEY_TEMPLATES.has(scaffoldTemplate) && scaffoldEnvironment === expectedEnvironment && expectedOrigin && scaffoldOrigins.includes(expectedOrigin) ); checks.push(scaffoldMatches ? pass('PASSKEY_SCAFFOLD_ENVIRONMENT', 'Scaffold environment matches the passkey target.', { environment: scaffoldEnvironment, origin: expectedOrigin, }) : fail( 'PASSKEY_SCAFFOLD_ENVIRONMENT', 'Scaffold environment/origin evidence is missing or mismatched.', 'Use a browser-capable scaffold and update its contract for the intended preview/production origin.', { found: Boolean(scaffold.found), template: scaffoldTemplate, scaffold_environment: scaffoldEnvironment, expected_environment: expectedEnvironment, origin_match: Boolean(expectedOrigin && scaffoldOrigins.includes(expectedOrigin)), } )); const failed = checks.filter((check) => check.status === 'fail'); return { schema_version: 'spaps.passkeys.doctor.v1', success: failed.length === 0, status: failed.length === 0 ? 'ready' : 'blocked', checks, next_actions: failed.map((check) => ({ code: check.code, action: check.fix_hint })), }; } function browserSupported(capabilities) { if (!capabilities) return null; return Boolean( capabilities.publicKeyCredential && capabilities.credentialsCreate && capabilities.credentialsGet ); } function buildPasskeyCeremonyPacket({ serverUrl = 'http://localhost:3301', origin = 'http://localhost:3000', rpId = 'localhost', browserCapabilities = null, requestRemoteMutation = false, allowRemote = false, } = {}) { const normalizedServerUrl = normalizeServerUrl(serverUrl); const normalizedOrigin = normalizeOrigin(origin); const normalizedRpId = normalizeHostname(rpId); const targetValid = Boolean( normalizedServerUrl && normalizedOrigin && normalizedRpId && isSecureBrowserOrigin(normalizedOrigin) ); const remote = normalizedServerUrl ? !isLocalUrl(normalizedServerUrl) : true; if (targetValid && remote && requestRemoteMutation && !allowRemote) { throw new PasskeyDiagnosticError( 'Passkey ceremony test refuses remote mutation without explicit operator intent.', { code: 'PASSKEY_REMOTE_MUTATION_REFUSED', recoverable: true, fixHint: 'Review the target, then rerun with --allow-remote to record explicit operator intent.', nextActions: [ 'Run spaps auth passkeys doctor against the exact remote RP/origin first.', 'Prefer the isolated local virtual-authenticator harness before any remote ceremony.', ], } ); } const supported = browserSupported(browserCapabilities); const packet = { schema_version: 'spaps.passkeys.ceremony.v1', success: targetValid && supported !== false, status: !targetValid || supported === false ? 'blocked' : 'ready-to-launch', target: { server_url: normalizedServerUrl, origin: normalizedOrigin, rp_id: normalizedRpId, remote, remote_operator_intent: targetValid && remote && requestRemoteMutation ? Boolean(allowRemote) : false, }, harness: { kind: 'local-chromium-virtual-authenticator', execution: 'not-run', scope: 'isolated-local-sdk-fixture', command: 'npm --prefix packages/sdk run test:browser-integration', }, preferred_client_api: [ 'client.auth.passkeys.register', 'client.auth.passkeys.signIn', 'client.auth.passkeys.createConditionalSignIn', ], native_json_parsers: [ 'PublicKeyCredential.parseCreationOptionsFromJSON', 'PublicKeyCredential.parseRequestOptionsFromJSON', ], safety: { terminal_emulates_webauthn: false, credential_bodies: 'never-output', credential_persistence: 'forbidden', secret_browser_keys: 'forbidden', }, next_actions: [ 'Run spaps auth passkeys doctor --json and resolve every failed check.', 'Launch the isolated local harness or perform the ceremony in the intended browser UI.', 'Handle an mfa_required result through the supported MFA branch.', ], }; if (!targetValid) { packet.error = { code: 'PASSKEY_TARGET_INVALID', type: 'INVALID_TARGET_URL', message: 'The passkey server URL, browser origin, or RP ID is invalid.', recoverable: true, fix_hint: 'Provide absolute server/origin URLs and the exact RP ID, then rerun passkeys doctor.', }; } else if (supported === false) { packet.error = { code: 'PASSKEY_BROWSER_UNSUPPORTED', type: 'UNSUPPORTED_BROWSER', message: 'This browser does not expose the WebAuthn APIs required for passkey ceremonies.', recoverable: true, fix_hint: 'Use a supported secure-context browser or keep the password/magic-link fallback visible.', }; } return packet; } function launchPasskeyCeremonyTest(options = {}, dependencies = {}) { const packet = buildPasskeyCeremonyPacket({ ...options, requestRemoteMutation: options.requestRemoteMutation ?? Boolean(options.launch), }); if (!options.launch || !packet.success) return packet; const repoRoot = dependencies.repoRoot || path.resolve(__dirname, '../../../..'); const spawnSync = dependencies.spawnSync || defaultSpawnSync; const args = [ '--prefix', path.join(repoRoot, 'packages/sdk'), 'run', 'test:browser-integration', ]; const completed = spawnSync('npm', args, { cwd: repoRoot, encoding: 'utf8', shell: false, stdio: 'pipe', env: { ...process.env, NO_COLOR: '1' }, }); const exitCode = Number.isInteger(completed.status) ? completed.status : 1; const result = { ...packet, success: exitCode === 0, status: exitCode === 0 ? 'pass' : 'fail', harness: { ...packet.harness, execution: 'completed' }, receipt: { status: exitCode === 0 ? 'pass' : 'fail', exit_code: exitCode, stdout_retained: false, stderr_retained: false, credential_bodies_retained: false, }, }; if (exitCode !== 0) { result.error = { code: 'PASSKEY_BROWSER_HARNESS_FAILED', type: 'BROWSER_HARNESS_UNAVAILABLE', message: 'The isolated local browser ceremony harness did not complete.', recoverable: true, fix_hint: 'Run npm --prefix packages/sdk run test:browser-integration to inspect local Chromium prerequisites, then retry.', next_actions: [ 'Install the local Playwright Chromium runtime dependencies reported by the canonical SDK harness.', 'Rerun spaps auth passkeys test --launch only after passkeys doctor is ready.', ], }; } return result; } function findUp(startDir, filename) { let current = path.resolve(startDir); while (true) { const candidate = path.join(current, filename); if (fs.existsSync(candidate)) return candidate; const parent = path.dirname(current); if (parent === current) return null; current = parent; } } function findUpRelative(startDir, relativePath) { let current = path.resolve(startDir); while (true) { const candidate = path.join(current, relativePath); if (fs.existsSync(candidate)) return candidate; const parent = path.dirname(current); if (parent === current) return null; current = parent; } } function readJson(filePath) { if (!filePath) return null; try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return null; } } function readScaffoldEvidence(cwd) { const contract = readJson(findUp(cwd, 'spaps.app.json')); if (!contract) { return { found: false, template: null, environment: null, allowed_origins: [] }; } const application = contract.spaps?.application || {}; return { found: true, template: contract.template || null, application_id: application.id || null, environment: application.environment || null, allowed_origins: Array.isArray(application.allowed_origins) ? application.allowed_origins : [], }; } function readInstalledSdk(cwd, override = null) { if (override) { return { installed: true, version: String(override), minimum_version: MIN_PASSKEY_SDK_VERSION, }; } const packageJson = readJson(findUpRelative(cwd, 'node_modules/spaps-sdk/package.json')); return packageJson?.version ? { installed: true, version: String(packageJson.version), minimum_version: MIN_PASSKEY_SDK_VERSION, } : { installed: false, version: null, minimum_version: MIN_PASSKEY_SDK_VERSION, }; } function readBrowserEnvironment(cwd, env) { const values = { ...env }; for (const filename of ['.env.local', '.env']) { const filePath = findUp(cwd, filename); if (!filePath) continue; try { Object.assign(values, parseEnvAssignments(fs.readFileSync(filePath, 'utf8'))); } catch {} } const key = values.NEXT_PUBLIC_SPAPS_PUBLISHABLE_KEY || values.VITE_SPAPS_PUBLISHABLE_KEY || values.NEXT_PUBLIC_SPAPS_API_KEY || values.VITE_SPAPS_API_KEY || values.SPAPS_API_KEY || ''; const type = String(key).startsWith('spaps_pub_') ? 'publishable' : String(key).startsWith('spaps_sec_') ? 'secret' : 'missing'; const scopeText = values.SPAPS_PUBLISHABLE_SCOPES || ''; return { type, scopes: String(scopeText).split(',').map((scope) => scope.trim()).filter(Boolean), }; } async function runPasskeyDoctor(options = {}, dependencies = {}) { const cwd = options.cwd || process.cwd(); const env = options.env || process.env; const fetchMethods = dependencies.fetchAuthMethods || fetchAuthMethods; const fetchProfile = dependencies.requestPasskeyProfile || requestPasskeyProfile; let discovery = null; let discoveryError = null; try { discovery = await fetchMethods({ port: options.port, serverUrl: options.serverUrl, origin: options.origin, cwd, env, axiosInstance: dependencies.axiosInstance, }); } catch (error) { discoveryError = error; } let profile = null; if (options.applicationId) { try { const result = await fetchProfile({ ...options, subcommand: 'passkeys.show', }, dependencies.authRequest); profile = result.profile || result; } catch {} } const webauthn = discovery?.methods?.find((method) => method.method === 'webauthn'); const scaffold = dependencies.readScaffoldEvidence ? dependencies.readScaffoldEvidence(cwd) : readScaffoldEvidence(cwd); const browserKey = dependencies.readBrowserEnvironment ? dependencies.readBrowserEnvironment(cwd, env) : readBrowserEnvironment(cwd, env); if (Array.isArray(options.publishableScopes) && options.publishableScopes.length) { browserKey.scopes = options.publishableScopes; } const responseCors = discovery?.headers?.['access-control-allow-origin']; const corsOrigins = Array.isArray(options.corsOrigins) && options.corsOrigins.length ? options.corsOrigins : responseCors ? [responseCors] : []; const expectedOrigin = options.origin || webauthn?.config?.origin || scaffold.allowed_origins[0] || null; const expectedEnvironment = options.environment || scaffold.environment || profile?.environment || null; const sdk = dependencies.readInstalledSdk ? dependencies.readInstalledSdk(cwd, options.sdkVersion) : readInstalledSdk(cwd, options.sdkVersion); return buildPasskeyDoctorReport({ server: { reachable: Boolean(discovery && !discoveryError), url: discovery?.serverUrl || options.serverUrl || null, }, discovery: { webauthn_enabled: Boolean(webauthn?.enabled), relying_party_id: webauthn?.config?.relying_party_id || null, origin: webauthn?.config?.origin || null, }, profile: profile || {}, expected: { rp_id: options.rpId || webauthn?.config?.relying_party_id || null, origin: expectedOrigin, environment: expectedEnvironment, }, browser_key: browserKey, cors: { allowed_origins: corsOrigins }, sdk, scaffold, }); } function renderPasskeyDoctor(report) { console.log(); console.log(`Passkey doctor: ${report.status}`); for (const check of report.checks) { console.log(` ${check.status === 'pass' ? 'PASS' : 'FAIL'} ${check.code}: ${check.summary}`); if (check.fix_hint) console.log(` fix: ${check.fix_hint}`); } console.log(); } function renderPasskeyCeremony(packet) { console.log(); console.log(`Passkey ceremony test: ${packet.status}`); console.log(` harness: ${packet.harness.kind} (${packet.harness.execution})`); console.log(' credential bodies: never output'); for (const action of packet.next_actions || []) console.log(` next: ${action}`); console.log(); } module.exports = { MIN_PASSKEY_SDK_VERSION, PasskeyDiagnosticError, buildPasskeyCeremonyPacket, buildPasskeyDoctorReport, launchPasskeyCeremonyTest, readBrowserEnvironment, readInstalledSdk, readScaffoldEvidence, renderPasskeyCeremony, renderPasskeyDoctor, runPasskeyDoctor, versionAtLeast, };