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