UNPKG

spaps

Version:

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

732 lines (685 loc) 21.7 kB
// Command handlers for `spaps login`, `spaps logout`, `spaps whoami`, // `spaps token`. Wired into the top-level handler map in src/handlers.js. // // Login uses RFC 8628 device flow (Tier 3). Tiers 1 (browser PKCE loopback) // and 2 (manual paste PKCE) are deferred until the SPAPS backend exposes the // `/auth/cli-login` / `/auth/callback` / `/auth/token` routes. const chalk = require('chalk'); const { startDeviceAuthorization, pollForToken, DeviceFlowError, } = require('./device-flow'); const { getCredentials, setCredentials, clearCredentials, CREDENTIALS_PATH, } = require('./credentials'); const { isHeadless, tryOpenBrowser } = require('./env'); const { authFetch, resolveServerUrl, isInteractionRequiredRefreshError, performCredentialRefresh, } = require('./client'); const { resolveLoginClientId } = require('./client-id'); const { fetchAuthMethods, runMfaTest, runSmsTest, } = require('./surface'); const { renderPasskeyProfile, requestPasskeyProfile } = require('./passkeys'); const { launchPasskeyCeremonyTest, renderPasskeyCeremony, renderPasskeyDoctor, runPasskeyDoctor, } = require('./passkey-diagnostics'); function emitJsonError(command, err, extra = {}) { console.log( JSON.stringify( { success: false, command, error: { code: err.code || 'ERROR', message: err.message || String(err), ...(typeof err.recoverable === 'boolean' ? { recoverable: err.recoverable } : {}), ...(err.fix_hint ? { fix_hint: err.fix_hint } : {}), ...(Array.isArray(err.next_actions) ? { next_actions: err.next_actions } : {}), }, ...extra, }, null, 2 ) ); } async function loginHandler({ options }) { const isJson = Boolean(options.json); const serverUrl = resolveServerUrl(options); const clientIdResult = await resolveLoginClientId({ options, serverUrl }); const clientId = clientIdResult.clientId; if (!clientId) { const err = new Error( 'Could not resolve a SPAPS application slug for device login. Pass --client-id <app-slug>, set SPAPS_CLI_CLIENT_ID, or run this command inside a repo with spaps.app.json / .spaps/app.json.' ); err.code = 'CLIENT_ID_REQUIRED'; if (isJson) { emitJsonError('login', err, { server_url: serverUrl }); } else { console.error(chalk.red(`\n❌ ${err.message}`)); } process.exit(2); } if (!isJson) { console.log(chalk.gray(`Requesting device code from ${serverUrl}...`)); if (clientIdResult.source) { const from = clientIdResult.path ? `${clientIdResult.source} (${clientIdResult.path})` : clientIdResult.source; console.log(chalk.gray(`Using client id ${chalk.cyan(clientId)} from ${from}`)); } } let authResult; try { authResult = await startDeviceAuthorization({ serverUrl, clientId }); } catch (err) { if (isJson) { emitJsonError('login', err, { server_url: serverUrl }); } else { console.error(chalk.red(`\n❌ ${err.message}`)); if (err.code === 'network_error') { console.error( chalk.gray(` Is a SPAPS server running at ${serverUrl}?`) ); console.error( chalk.gray(` Try: npx spaps local (or set SPAPS_API_URL)`) ); } } process.exit(1); } const verificationUri = authResult.verification_uri_complete || authResult.verification_uri || authResult.auth_url; if (!isJson) { console.log(); console.log(chalk.bold('To finish signing in:')); console.log(); console.log(` 1. Visit: ${chalk.cyan(verificationUri)}`); if (authResult.user_code) { console.log(` 2. Confirm code: ${chalk.bold.yellow(authResult.user_code)}`); } console.log(); if (isHeadless()) { console.log( chalk.gray( '(Headless session detected — open the URL on another device)' ) ); } else if (tryOpenBrowser(verificationUri)) { console.log(chalk.gray('(Opened browser automatically)')); } else { console.log( chalk.gray('(Could not auto-open a browser — visit the URL manually)') ); } console.log(); console.log(chalk.gray('Waiting for approval...')); } let tokenPayload; try { tokenPayload = await pollForToken({ serverUrl, deviceCode: authResult.device_code, clientId, interval: authResult.interval, expiresIn: authResult.expires_in, onTick: (tick) => { if (!isJson && tick.status === 'slow_down') { console.log( chalk.gray(` (server requested slow down — interval=${tick.interval}s)`) ); } }, }); } catch (err) { if (isJson) { emitJsonError('login', err, { server_url: serverUrl }); } else { console.error(chalk.red(`\n❌ ${err.message}`)); if (err instanceof DeviceFlowError && err.code === 'access_denied') { console.error(chalk.gray(' You (or another session) denied the request.')); } else if (err instanceof DeviceFlowError && err.code === 'expired_token') { console.error(chalk.gray(' Run `spaps login` again to retry.')); } } process.exit(1); } const nowSec = Math.floor(Date.now() / 1000); const expiresAt = tokenPayload.expires_in ? nowSec + Number(tokenPayload.expires_in) : null; const storage = setCredentials(serverUrl, { access_token: tokenPayload.access_token, refresh_token: tokenPayload.refresh_token || null, token_type: tokenPayload.token_type || 'Bearer', expires_in: tokenPayload.expires_in || null, expires_at: expiresAt, user_id: tokenPayload.user_id || null, client_id: clientId, session_id: tokenPayload.session_id || null, }, { // Login is the only flow allowed to unlock an OS credential store. All // subsequent token reads and refreshes use the encrypted file mirror so // background/headless commands can never trigger a keyring prompt. allowKeyringPrompt: true, }); if (isJson) { console.log( JSON.stringify( { success: true, command: 'login', server_url: serverUrl, client_id: clientId, user_id: tokenPayload.user_id || null, expires_at: expiresAt, credential_storage: storage.primary, credentials_path: CREDENTIALS_PATH, }, null, 2 ) ); return; } console.log(); console.log(chalk.green('✅ Logged in to ') + chalk.cyan(serverUrl)); if (tokenPayload.user_id) { console.log(chalk.gray(` user_id: ${tokenPayload.user_id}`)); } const primary = storage.primary === 'keyring' ? 'OS keyring' : 'encrypted credential file'; console.log(chalk.gray(` credentials saved to ${primary}`)); console.log(chalk.gray(` encrypted fallback: ${CREDENTIALS_PATH} (mode 0600)`)); console.log(); } async function logoutHandler({ options }) { const isJson = Boolean(options.json); const serverUrl = resolveServerUrl(options); const creds = getCredentials(serverUrl); if (!creds || !creds.access_token) { if (isJson) { console.log( JSON.stringify( { success: true, command: 'logout', server_url: serverUrl, already_logged_out: true, }, null, 2 ) ); return; } console.log(chalk.yellow(`⚠️ Not currently logged in to ${serverUrl}`)); return; } // Best-effort server-side revoke. We intentionally do not refresh the access // token if it has expired — the point of logout is to drop the session, and // a failed revoke still clears local credentials. let serverRevoked = false; let revokeError = null; try { const res = await authFetch('/auth/logout', { serverUrl, method: 'POST', body: creds.refresh_token ? { refresh_token: creds.refresh_token } : {}, allowRefresh: false, }); serverRevoked = res.status >= 200 && res.status < 300; if (!serverRevoked) { revokeError = `HTTP ${res.status}`; } } catch (err) { revokeError = err.message || String(err); } clearCredentials(serverUrl); if (isJson) { console.log( JSON.stringify( { success: true, command: 'logout', server_url: serverUrl, server_revoked: serverRevoked, revoke_error: revokeError, }, null, 2 ) ); return; } console.log(chalk.green(`✅ Logged out of ${serverUrl}`)); if (!serverRevoked) { console.log( chalk.gray(' (server-side revoke failed — local credentials cleared)') ); } } async function whoamiHandler({ options }) { const isJson = Boolean(options.json); const serverUrl = resolveServerUrl(options); let res; try { res = await authFetch('/auth/user', { serverUrl, method: 'GET' }); } catch (err) { if (isJson) { emitJsonError('whoami', err, { server_url: serverUrl }); } else { console.error(chalk.red(`\n❌ ${err.message}`)); if (err.code === 'NOT_AUTHENTICATED') { console.error(chalk.gray(' Run: npx spaps login')); } else if (err.code === 'SESSION_EXPIRED') { console.error(chalk.gray(' Run: npx spaps login (your session expired)')); } else if (err.code === 'INTERACTION_REQUIRED') { console.error(chalk.gray(' Run: npx spaps login (device login required)')); } } process.exit( err.code === 'NOT_AUTHENTICATED' || err.code === 'SESSION_EXPIRED' || err.code === 'INTERACTION_REQUIRED' ? 2 : 1 ); } if (res.status >= 400) { const msg = (res.data && (res.data.detail || res.data.message)) || `HTTP ${res.status}`; const code = (res.data && res.data.code) || `HTTP_${res.status}`; if (isJson) { console.log( JSON.stringify( { success: false, command: 'whoami', server_url: serverUrl, status: res.status, error: { code, message: msg }, }, null, 2 ) ); } else { console.error(chalk.red(`\n❌ ${msg}`)); } process.exit(1); } const user = res.data && res.data.user ? res.data.user : res.data; if (isJson) { console.log( JSON.stringify( { success: true, command: 'whoami', server_url: serverUrl, user }, null, 2 ) ); return; } console.log(); console.log(chalk.bold('Logged in as:')); console.log( ' ' + chalk.cyan(user.email || user.username || user.id || '(unknown)') ); if (user.id) console.log(chalk.gray(` id: ${user.id}`)); if (Array.isArray(user.roles) && user.roles.length) { console.log(chalk.gray(` roles: ${user.roles.join(', ')}`)); } if (user.tier) console.log(chalk.gray(` tier: ${user.tier}`)); console.log(chalk.gray(` server: ${serverUrl}`)); console.log(); } async function tokenHandler({ options }) { const isJson = Boolean(options.json); const serverUrl = resolveServerUrl(options); // Env-var bypass (CI): print it and exit. Skips file read/write entirely. if (process.env.SPAPS_ACCESS_TOKEN) { if (isJson) { console.log( JSON.stringify( { success: true, command: 'token', source: 'env', access_token: process.env.SPAPS_ACCESS_TOKEN, }, null, 2 ) ); return; } process.stdout.write(process.env.SPAPS_ACCESS_TOKEN + '\n'); return; } const creds = getCredentials(serverUrl); if (!creds || !creds.access_token) { const msg = `Not authenticated to ${serverUrl}. Run: npx spaps login`; if (isJson) { console.log( JSON.stringify( { success: false, command: 'token', server_url: serverUrl, error: { code: 'NOT_AUTHENTICATED', message: msg }, }, null, 2 ) ); } else { console.error(chalk.red(`❌ ${msg}`)); } process.exit(2); } const printTokenRefreshError = ({ code, message, expiresAt, cause }) => { const loginCommand = `spaps login --server-url ${serverUrl}`; if (isJson) { console.log( JSON.stringify( { success: false, command: 'token', server_url: serverUrl, expires_at: expiresAt || null, login_command: loginCommand, error: { code, message, ...(cause && cause.code ? { cause_code: cause.code } : {}), ...(cause && cause.stableCode ? { cause_stable_code: cause.stableCode } : {}), ...(cause && cause.status ? { cause_status: cause.status } : {}), }, }, null, 2 ) ); } else { console.error(chalk.red(`❌ ${message}`)); console.error(chalk.gray(` Run: ${loginCommand}`)); } process.exit(2); }; // `--refresh` forces a token refresh via the public-client refresh grant even // when the stored token is still valid. It reuses the same locked, rotation- // race-safe refresh path as the auto-refresh below so concurrent keepwarm/CLI // callers stay safe. if (options.refresh) { if (!creds.refresh_token) { printTokenRefreshError({ code: 'NO_REFRESH_TOKEN', expiresAt: creds.expires_at, message: `No refresh token is available to force a refresh for ${serverUrl}. Re-authenticate to ${serverUrl}.`, }); } try { const updated = await performCredentialRefresh({ serverUrl, force: true }); if (isJson) { console.log( JSON.stringify( { success: true, command: 'token', source: 'refreshed', access_token: updated.access_token, expires_at: updated.expires_at || null, }, null, 2 ) ); } else { process.stdout.write(updated.access_token + '\n'); } return; } catch (err) { if (isInteractionRequiredRefreshError(err)) { printTokenRefreshError({ code: 'INTERACTION_REQUIRED', expiresAt: creds.expires_at, message: `Stored SPAPS session requires device login. Re-authenticate to ${serverUrl}.`, cause: err, }); } printTokenRefreshError({ code: 'TOKEN_REFRESH_FAILED', expiresAt: creds.expires_at, message: `Forced token refresh failed (refresh token may be expired or revoked). Re-authenticate to ${serverUrl}.`, cause: err, }); } } // If the token is within 30s of expiry, try a silent refresh so the caller // gets a fresh token. Never print an expired/stale token after refresh fails: // downstream consumers otherwise surface confusing 401s. const nowSec = Math.floor(Date.now() / 1000); const tokenNeedsRefresh = Boolean(creds.expires_at && creds.expires_at - 30 < nowSec); if (tokenNeedsRefresh && creds.refresh_token) { try { const updated = await performCredentialRefresh({ serverUrl }); const newExpiresAt = updated.expires_at || null; if (isJson) { console.log( JSON.stringify( { success: true, command: 'token', source: 'refreshed', access_token: updated.access_token, expires_at: newExpiresAt, }, null, 2 ) ); } else { process.stdout.write(updated.access_token + '\n'); } return; } catch (err) { if (isInteractionRequiredRefreshError(err)) { printTokenRefreshError({ code: 'INTERACTION_REQUIRED', expiresAt: creds.expires_at, message: `Stored SPAPS session requires device login. Re-authenticate to ${serverUrl}.`, cause: err, }); } printTokenRefreshError({ code: 'TOKEN_REFRESH_FAILED', expiresAt: creds.expires_at, message: `Stored SPAPS access token is expired or expiring, and refresh failed. Re-authenticate to ${serverUrl}.`, cause: err, }); } } if (tokenNeedsRefresh) { printTokenRefreshError({ code: 'TOKEN_EXPIRED', expiresAt: creds.expires_at, message: `Stored SPAPS access token is expired or expiring, and no refresh token is available. Re-authenticate to ${serverUrl}.`, }); } if (isJson) { console.log( JSON.stringify( { success: true, command: 'token', source: 'stored', access_token: creds.access_token, expires_at: creds.expires_at || null, }, null, 2 ) ); return; } process.stdout.write(creds.access_token + '\n'); } function renderMethods(methods) { console.log(); console.log(chalk.bold('Auth methods')); for (const method of methods) { const status = method.enabled ? chalk.green('enabled') : chalk.gray('disabled'); const config = method.config && Object.keys(method.config).length ? ` ${chalk.gray(JSON.stringify(method.config))}` : ''; console.log(` ${method.method}: ${status}${config}`); } console.log(); } function renderProbeResult(result) { console.log(); console.log(chalk.green(`✓ ${result.command} completed`)); if (Array.isArray(result.steps)) { for (const step of result.steps) { const mark = step.success ? chalk.green('✓') : chalk.yellow('!'); console.log(` ${mark} ${step.name}`); } } if (result.verification_required) { console.log(chalk.yellow(' verification required')); console.log(chalk.gray(` challenge_id: ${result.challenge_id}`)); console.log(chalk.gray(` next: ${result.next_step}`)); } console.log(); } async function authHandler({ options }) { const isJson = Boolean(options.json); const serverUrl = resolveServerUrl(options); const command = `auth.${options.subcommand || 'unknown'}`; try { if (options.subcommand === 'methods') { const result = await fetchAuthMethods({ serverUrl, origin: options.origin || null, }); const payload = { success: true, command: 'auth.methods', server_url: serverUrl, origin: result.origin, api_key_source: result.apiKeySource, methods: result.methods, }; if (isJson) { console.log(JSON.stringify(payload, null, 2)); } else { renderMethods(result.methods); } return; } if (options.subcommand === 'mfa-test') { const result = await runMfaTest({ serverUrl, origin: options.origin || null, email: options.email || process.env.SPAPS_TEST_EMAIL || null, password: options.password || process.env.SPAPS_TEST_PASSWORD || null, allowRemote: Boolean(options.allowRemote), }); if (isJson) { console.log(JSON.stringify(result, null, 2)); } else { renderProbeResult(result); } return; } if (options.subcommand === 'sms-test') { const result = await runSmsTest({ serverUrl, origin: options.origin || null, phoneNumber: options.phoneNumber || process.env.SPAPS_TEST_PHONE_NUMBER || null, challengeId: options.challengeId || null, code: options.code || null, allowRemote: Boolean(options.allowRemote), }); if (isJson) { console.log(JSON.stringify(result, null, 2)); } else { renderProbeResult(result); } return; } if (options.subcommand === 'passkeys.doctor') { const result = await runPasskeyDoctor({ ...options, serverUrl }); if (isJson) { console.log(JSON.stringify(result, null, 2)); } else { renderPasskeyDoctor(result); } if (!result.success) process.exitCode = 1; return; } if (options.subcommand === 'passkeys.test') { const result = launchPasskeyCeremonyTest({ ...options, serverUrl, requestRemoteMutation: Boolean(options.launch), }); if (isJson) { console.log(JSON.stringify(result, null, 2)); } else { renderPasskeyCeremony(result); } if (!result.success) process.exitCode = 1; return; } if (String(options.subcommand).startsWith('passkeys.')) { const result = await requestPasskeyProfile({ ...options, serverUrl }); const payload = { success: true, command: options.subcommand, server_url: serverUrl, profile: result.profile || result, ...(result.diagnostics ? { diagnostics: result.diagnostics } : {}), }; if (isJson) { console.log(JSON.stringify(payload, null, 2)); } else { renderPasskeyProfile(options.subcommand.split('.')[1], result); } return; } const err = new Error('Unknown auth subcommand. Use `spaps auth --help`.'); err.code = 'UNKNOWN_AUTH_SUBCOMMAND'; throw err; } catch (err) { if (isJson) { emitJsonError(command, err, { server_url: serverUrl }); } else { console.error(chalk.red(`\n❌ ${err.message || String(err)}`)); } process.exit(err.code === 'MISSING_ARGUMENT' || err.code === 'REMOTE_REFUSED' ? 2 : 1); } } module.exports = { authHandler, loginHandler, logoutHandler, whoamiHandler, tokenHandler, };