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