UNPKG

spaps

Version:

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

363 lines (330 loc) 10.1 kB
// Authenticated HTTP client for the SPAPS CLI. // // Wraps axios with: // - bearer token injection from stored credentials // - SPAPS_ACCESS_TOKEN env-var bypass (CI / machine use) // - automatic one-shot refresh on 401 via POST /auth/refresh, writing the // rotated pair back to the credentials store const axios = require('axios'); const { DEFAULT_PORT } = require('../config'); const { getCredentials, setCredentials, withCredentialsLockAsync, } = require('./credentials'); const { resolveAuthApiKey } = require('./api-key'); const { buildApiUrl, extractApiError, unwrapApiData } = require('./http'); const AUTH_BEARER_ONLY_PREFIXES = [ '/auth/user', '/auth/logout', '/auth/sessions', ]; function resolveServerUrl(options = {}) { if (options.serverUrl) return String(options.serverUrl).replace(/\/+$/, ''); if (process.env.SPAPS_API_URL) return String(process.env.SPAPS_API_URL).replace(/\/+$/, ''); const port = options.port || DEFAULT_PORT; return `http://localhost:${port}`; } function normalizeApiPath(pathOrUrl) { if (/^https?:\/\//.test(pathOrUrl)) { try { const pathname = new URL(pathOrUrl).pathname; return pathname.startsWith('/api/') ? pathname.slice('/api'.length) : pathname; } catch { return pathOrUrl; } } if (pathOrUrl.startsWith('/api/')) { return pathOrUrl.slice('/api'.length); } return pathOrUrl.startsWith('/') ? pathOrUrl : `/${pathOrUrl}`; } function isAuthBearerOnlyPath(pathOrUrl) { const normalized = normalizeApiPath(pathOrUrl).replace(/\/+$/, '') || '/'; return AUTH_BEARER_ONLY_PREFIXES.some( (prefix) => normalized === prefix || normalized.startsWith(`${prefix}/`) ); } function isPublicClientCredentials(creds) { return Boolean(creds && creds.client_id); } function shouldSkipApiKey({ storedCreds, pathOrUrl }) { if (!isPublicClientCredentials(storedCreds)) { return false; } return isAuthBearerOnlyPath(pathOrUrl); } function buildRequestHeaders(headers = {}, cwd = process.cwd(), options = {}) { if (options.skipApiKey) { return headers; } const resolved = resolveAuthApiKey({ cwd }); if ( !resolved.apiKey || Object.prototype.hasOwnProperty.call(headers, 'X-API-Key') || Object.prototype.hasOwnProperty.call(headers, 'x-api-key') ) { return headers; } return { ...headers, 'X-API-Key': resolved.apiKey, }; } function normalizeErrorToken(value) { return String(value || '').trim().toLowerCase(); } function enrichRefreshError(err, payload, status) { const apiError = extractApiError(payload || {}, status); const envelopeError = payload && typeof payload === 'object' && payload.success === false && payload.error && typeof payload.error === 'object' ? payload.error : null; const details = envelopeError?.details || null; const stableCode = (details && details.stable_code) || envelopeError?.stable_code || null; const out = err instanceof Error ? err : new Error(apiError.message || String(err)); out.code = apiError.code || out.code || 'REFRESH_FAILED'; out.status = status; out.stableCode = stableCode; out.details = details; out.message = apiError.message || out.message; return out; } function isInteractionRequiredRefreshError(err) { const code = normalizeErrorToken(err && err.code); const stable = normalizeErrorToken(err && err.stableCode); if (code === 'interaction_required' || stable === 'interaction_required') { return true; } if (err && err.details && err.details.interaction_required === true) { return true; } if (code === 'refresh_token_expired' || code === 'refresh_idle_ttl') { return true; } return false; } function isRotationRaceRefreshError(err) { const code = normalizeErrorToken(err && err.code); const stable = normalizeErrorToken(err && err.stableCode); return ( code === 'refresh_token_replay' || code === 'already_rotated' || code === 'rotation_in_progress' || stable === 'already_rotated' || stable === 'rotation_in_progress' ); } function tokenLooksFresh(creds, skewSec = 30) { if (!creds || !creds.access_token) { return false; } const nowSec = Math.floor(Date.now() / 1000); if (!creds.expires_at) { return true; } return creds.expires_at - skewSec >= nowSec; } function mergeRefreshedCredentials(existing, refreshed, nowSec) { return { ...existing, access_token: refreshed.access_token, refresh_token: refreshed.refresh_token || existing.refresh_token, expires_in: refreshed.expires_in || existing.expires_in, expires_at: refreshed.expires_in ? nowSec + Number(refreshed.expires_in) : existing.expires_at, token_type: refreshed.token_type || existing.token_type || 'Bearer', client_id: existing.client_id || null, session_id: refreshed.session_id || existing.session_id || null, }; } async function refreshAccessToken({ serverUrl, refreshToken, clientId = null, axiosInstance = axios, headers = {}, cwd = process.cwd(), }) { const body = { refresh_token: refreshToken }; if (clientId) { body.client_id = clientId; } const res = await axiosInstance.post( buildApiUrl(serverUrl, '/auth/refresh'), body, { headers: buildRequestHeaders( { 'Content-Type': 'application/json', ...headers }, cwd, { skipApiKey: Boolean(clientId) } ), validateStatus: () => true, } ); if (res.status >= 400) { const baseErr = new Error( extractApiError(res.data || {}, res.status).message || `refresh failed (HTTP ${res.status})` ); throw enrichRefreshError(baseErr, res.data || {}, res.status); } return unwrapApiData(res.data); } async function performCredentialRefresh({ serverUrl, cwd = process.cwd(), axiosInstance = axios, allowRotationRetry = true, force = false, }) { return withCredentialsLockAsync(async () => { const creds = getCredentials(serverUrl); if (!creds || !creds.refresh_token) { const err = new Error('Not authenticated. Run `spaps login` first.'); err.code = 'NOT_AUTHENTICATED'; throw err; } // `force` bypasses the fresh-token fast-path so callers (e.g. `spaps token // --refresh`) always hit POST /auth/refresh and receive a rotated token. if (!force && tokenLooksFresh(creds)) { return creds; } const beforeAccessToken = creds.access_token; try { const refreshed = await refreshAccessToken({ serverUrl, refreshToken: creds.refresh_token, clientId: creds.client_id || null, axiosInstance, cwd, }); const nowSec = Math.floor(Date.now() / 1000); const updated = mergeRefreshedCredentials(creds, refreshed, nowSec); setCredentials(serverUrl, updated); return updated; } catch (err) { if (allowRotationRetry && isRotationRaceRefreshError(err)) { const reread = getCredentials(serverUrl); if ( reread && reread.access_token && reread.access_token !== beforeAccessToken && tokenLooksFresh(reread) ) { return reread; } } throw err; } }); } async function authFetch( pathOrUrl, { serverUrl, method = 'GET', body = null, headers = {}, axiosInstance = axios, allowRefresh = true, cwd = process.cwd(), } = {} ) { const base = serverUrl || resolveServerUrl(); const url = /^https?:\/\//.test(pathOrUrl) ? pathOrUrl : buildApiUrl(base, pathOrUrl); // CI / machine bypass: an explicit env-var token skips the credentials file // entirely and opts out of refresh. let accessToken = process.env.SPAPS_ACCESS_TOKEN || null; let storedCreds = null; if (!accessToken) { storedCreds = getCredentials(base); if (!storedCreds || !storedCreds.access_token) { const err = new Error('Not authenticated. Run `spaps login` first.'); err.code = 'NOT_AUTHENTICATED'; throw err; } accessToken = storedCreds.access_token; } const skipApiKey = shouldSkipApiKey({ storedCreds, pathOrUrl }); const doRequest = (tokenToUse) => axiosInstance({ url, method, data: body, headers: buildRequestHeaders({ ...headers, Authorization: `Bearer ${tokenToUse}`, ...(body ? { 'Content-Type': 'application/json' } : {}), }, cwd, { skipApiKey }), validateStatus: () => true, }); let res = await doRequest(accessToken); const initialError = res.status === 401 ? extractApiError(res.data || {}, res.status) : null; const shouldAttemptRefresh = !( initialError && typeof initialError.code === 'string' && initialError.code.toUpperCase() === 'INVALID_APPLICATION' ); if ( res.status === 401 && storedCreds && storedCreds.refresh_token && allowRefresh && shouldAttemptRefresh ) { try { const updated = await performCredentialRefresh({ serverUrl: base, axiosInstance, cwd, }); res = await doRequest(updated.access_token); } catch (err) { if (err.code === 'INVALID_APPLICATION') { throw err; } if (isInteractionRequiredRefreshError(err)) { const authErr = new Error( 'Device login required. Run `spaps login` again.' ); authErr.code = 'INTERACTION_REQUIRED'; authErr.cause = err; throw authErr; } const authErr = new Error('Session expired. Run `spaps login` again.'); authErr.code = 'SESSION_EXPIRED'; authErr.cause = err; throw authErr; } } return { ...res, raw: res.data, data: unwrapApiData(res.data), }; } module.exports = { AUTH_BEARER_ONLY_PREFIXES, buildRequestHeaders, isAuthBearerOnlyPath, isInteractionRequiredRefreshError, isPublicClientCredentials, isRotationRaceRefreshError, mergeRefreshedCredentials, performCredentialRefresh, resolveServerUrl, refreshAccessToken, authFetch, tokenLooksFresh, };