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