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