UNPKG

spaps

Version:

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

160 lines (143 loc) 4.62 kB
// RFC 8628 device authorization grant client for the SPAPS CLI. // // Wraps the server endpoints under /api/cli/device/{authorize,verify,token}: // - startDeviceAuthorization() -> POST /api/cli/device/authorize // - pollForToken() -> POST /api/cli/device/token (polling) // // The `verify` endpoint is JWT-gated and driven by the user's browser session, // not this client. const axios = require('axios'); const { buildApiUrl, extractApiError, unwrapApiData } = require('./http'); const GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'; const DEFAULT_CLIENT_ID = null; class DeviceFlowError extends Error { constructor(code, message) { super(message || code); this.name = 'DeviceFlowError'; this.code = code; } } async function startDeviceAuthorization({ serverUrl, clientId = DEFAULT_CLIENT_ID, axiosInstance = axios, }) { if (!serverUrl || !clientId) { throw new DeviceFlowError('config_error', 'serverUrl and clientId are required'); } let res; try { res = await axiosInstance.post( buildApiUrl(serverUrl, '/cli/device/authorize'), { client_id: clientId }, { headers: { 'Content-Type': 'application/json' }, validateStatus: () => true, } ); } catch (err) { throw new DeviceFlowError( 'network_error', `Failed to reach SPAPS at ${serverUrl}: ${err.message || err}` ); } if (res.status >= 400) { const body = res.data || {}; const apiError = extractApiError(body, res.status); throw new DeviceFlowError( apiError.code || 'authorize_failed', apiError.message || `HTTP ${res.status}` ); } return unwrapApiData(res.data); } function defaultSleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function pollForToken({ serverUrl, deviceCode, clientId = DEFAULT_CLIENT_ID, interval = 5, expiresIn = 900, axiosInstance = axios, onTick = null, clock = Date.now, sleepFn = defaultSleep, }) { if (!serverUrl || !deviceCode || !clientId) { throw new DeviceFlowError( 'config_error', 'serverUrl, deviceCode, and clientId are required' ); } const startTime = clock(); const expiresAtMs = startTime + Number(expiresIn) * 1000; // Use Number.isFinite so an explicit 0 is respected (tests use interval=0 // to skip real sleeps; `0 || 5` would clobber that to 5). const parsedInterval = Number(interval); let currentInterval = Math.max( 0, Number.isFinite(parsedInterval) ? parsedInterval : 5 ); // Loop until the server returns success, an unrecoverable error, or the // device code TTL is exhausted. while (clock() < expiresAtMs) { let res; try { res = await axiosInstance.post( buildApiUrl(serverUrl, '/cli/device/token'), { grant_type: GRANT_TYPE, device_code: deviceCode, client_id: clientId, }, { headers: { 'Content-Type': 'application/json' }, validateStatus: () => true, } ); } catch (err) { throw new DeviceFlowError( 'network_error', `Failed to reach SPAPS during poll: ${err.message || err}` ); } const responseData = unwrapApiData(res.data); if (res.status >= 200 && res.status < 300 && responseData && responseData.access_token) { return responseData; } const apiError = extractApiError(res.data || {}, res.status); const errorCode = apiError.code || `http_${res.status}`; const errorDesc = apiError.message || ''; if (errorCode === 'authorization_pending') { if (onTick) onTick({ status: 'pending', interval: currentInterval }); await sleepFn(currentInterval * 1000); continue; } if (errorCode === 'slow_down') { currentInterval += 5; if (onTick) onTick({ status: 'slow_down', interval: currentInterval }); await sleepFn(currentInterval * 1000); continue; } if (errorCode === 'access_denied') { throw new DeviceFlowError('access_denied', errorDesc || 'User denied the authorization request'); } if (errorCode === 'expired_token') { throw new DeviceFlowError('expired_token', errorDesc || 'Device code expired before authorization'); } throw new DeviceFlowError( errorCode, errorDesc || `Device token exchange failed (HTTP ${res.status})` ); } throw new DeviceFlowError('expired_token', 'Timed out waiting for user authorization'); } module.exports = { DEFAULT_CLIENT_ID, GRANT_TYPE, DeviceFlowError, startDeviceAuthorization, pollForToken, };