UNPKG

@vigilbase/cli

Version:

Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.

240 lines (239 loc) • 10.3 kB
import { z } from 'zod'; import { validateOneOrigin } from '../lib/config.js'; import { NetworkError, parseJsonResponse, requestText } from '../lib/network.js'; const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; /** How many consecutive unreachable token polls before aborting a live login. */ const MAX_CONSECUTIVE_TRANSIENT = 8; export class DeviceAuthError extends Error { code; status; body; /** The message is the CLI's own text, never the server's (see VigilbaseApiError). */ isCliAuthored; constructor(message, options) { super(message); this.name = 'DeviceAuthError'; this.status = options.status; this.code = options.code; this.body = options.body; this.isCliAuthored = options.isCliAuthored ?? false; } } function authBase(config) { return `${validateOneOrigin(config.oneBaseUrl)}/api/auth`; } function isRetryableTokenStatus(status) { return (status === 404 || status === 408 || status === 425 || status === 429 || status >= 500); } async function postJson(config, path, body) { const provider = config.authProvider; const url = provider ? (path === '/device/code' ? provider.deviceEndpoint : provider.tokenEndpoint) : `${authBase(config)}${path}`; const form = provider && path === '/device/token'; const { response, text } = await requestText(config, url, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': form ? 'application/x-www-form-urlencoded' : 'application/json', 'User-Agent': config.userAgent, }, body: form ? new URLSearchParams(Object.entries(body).map(([key, value]) => [key, String(value)])) : JSON.stringify(body), }); return { status: response.status, json: parseJsonResponse(response, text), origin: new URL(url).origin }; } export async function requestDeviceCode(config, options) { const { status, json, origin } = await postJson(config, '/device/code', { client_id: config.clientId, ...(config.authProvider ? { resource: config.authProvider.resource, scope: 'openid profile email offline_access one:api' } : {}), ...(options?.scope ? { scope: options.scope } : {}), }); // origin is the Identity issuer, or One for legacy sign-in. if (status === 404) { throw new DeviceAuthError(`Device sign-in isn't available at ${origin} right now. ` + 'If you set --one-url or --auth-url, check that it\'s correct; otherwise try again later or email help@vigilbase.com. ' + 'Public tools work without signing in.', { status, code: 'not_enabled' }); } if (status < 200 || status >= 300) { throw new DeviceAuthError(`Device code request failed (HTTP ${status})`, { status }); } const result = z.object({ device_code: z.string().min(1), user_code: z.string().min(1), verification_uri: z.string().min(1), verification_uri_complete: z.string().min(1).optional(), expires_in: z.number().int().positive().max(86_400).default(600), interval: z.number().int().positive().max(86_400).default(5), }).safeParse(json); if (!result.success) { throw new DeviceAuthError('Device code response was missing required fields or contained invalid values.', { status, code: 'invalid_response', }); } return result.data; } export async function pollDeviceToken(config, deviceCode) { let status; let json; try { ; ({ status, json } = await postJson(config, '/device/token', { grant_type: DEVICE_GRANT, device_code: deviceCode, client_id: config.clientId, ...(config.authProvider ? { resource: config.authProvider.resource } : {}), })); } catch (error) { if (error instanceof NetworkError && /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER|INVALID_RESPONSE/.test(error.code)) throw error; const description = error instanceof Error ? error.message : 'Network error while polling'; return { status: 'transient', httpStatus: 0, error: { error: 'authorization_pending', error_description: description, }, }; } const token = z.object({ access_token: z.string().trim().min(1), token_type: z.string().optional(), expires_in: z.number().int().positive().optional(), refresh_token: z.string().optional(), scope: z.string().optional(), }).safeParse(json); if (status >= 200 && status < 300 && token.success) { return { status: 'authorized', token: token.data }; } if (status >= 200 && status < 300) { throw new DeviceAuthError('Invalid device token response.', { status, code: 'invalid_response' }); } const parsedError = z.object({ error: z.string(), error_description: z.string().optional(), }).safeParse(json); const errorBody = parsedError.success ? parsedError.data : { error: `http_${status}` }; const code = errorBody.error; switch (code) { case 'authorization_pending': case 'slow_down': return { status: 'pending', error: errorBody }; case 'access_denied': return { status: 'denied', error: errorBody }; case 'expired_token': return { status: 'expired', error: errorBody }; default: break; } // After a successful device/code, brief 404/5xx (deploys, rollbacks, blips) // must not abort login as "not enabled". if (isRetryableTokenStatus(status)) { return { status: 'transient', httpStatus: status, error: { error: 'authorization_pending', error_description: errorBody.error_description || errorBody.error || `Temporary device token error (HTTP ${status})`, }, }; } throw new DeviceAuthError(`Device token request failed (HTTP ${status})`, { status, code, body: errorBody, isCliAuthored: true }); } export async function sleep(ms, signal) { await new Promise((resolve, reject) => { const cancel = () => { clearTimeout(timer); signal?.removeEventListener('abort', cancel); reject(new DeviceAuthError('Login cancelled.', { status: 0, code: 'cancelled' })); }; const timer = setTimeout(() => { signal?.removeEventListener('abort', cancel); resolve(); }, ms); signal?.addEventListener('abort', cancel, { once: true }); if (signal?.aborted) cancel(); }); } /** * Run the full device-code poll loop until authorized, denied, or expired. * Returns the token payload on success. */ export async function waitForDeviceAuthorization(config, device, options) { let intervalSec = device.interval && device.interval > 0 ? device.interval : 5; const deadline = Date.now() + device.expires_in * 1000; let attempt = 0; let consecutiveTransient = 0; const maxTransient = options?.maxConsecutiveTransient ?? MAX_CONSECUTIVE_TRANSIENT; let lastTransientStatus = 0; while (Date.now() < deadline) { if (options?.signal?.aborted) { throw new DeviceAuthError('Login cancelled.', { status: 0, code: 'cancelled', }); } attempt += 1; options?.onPending?.({ attempt, intervalSec, kind: consecutiveTransient > 0 ? 'transient' : 'pending', }); await sleep(Math.min(intervalSec * 1000, Math.max(0, deadline - Date.now())), options?.signal); if (Date.now() >= deadline) break; const result = await pollDeviceToken(config, device.device_code); switch (result.status) { case 'authorized': return result.token; case 'pending': consecutiveTransient = 0; if (result.error.error === 'slow_down') { intervalSec += 5; } break; case 'transient': consecutiveTransient += 1; lastTransientStatus = result.httpStatus; if (consecutiveTransient >= maxTransient) { // After an HTTP error the description is the server's, so only a network failure's // own (CLI-authored) description joins the message. const detail = (!lastTransientStatus && result.error.error_description) || 'Retry `vigilbase login` once One is healthy.'; throw new DeviceAuthError(`Lost contact with device authorization on ${config.oneBaseUrl} while waiting for approval` + (lastTransientStatus ? ` (HTTP ${lastTransientStatus})` : '') + `. ${detail}`, { status: lastTransientStatus, code: 'unreachable', body: result.error, isCliAuthored: true, }); } break; case 'denied': throw new DeviceAuthError('Access was denied in the browser.', { status: 403, code: 'access_denied', body: result.error, isCliAuthored: true, }); case 'expired': throw new DeviceAuthError('The device code expired. Run `vigilbase login` again.', { status: 400, code: 'expired_token', body: result.error, isCliAuthored: true }); default: { const _exhaustive = result; throw new DeviceAuthError(`Unexpected poll result: ${JSON.stringify(_exhaustive)}`, { status: 0, code: 'unexpected', }); } } } throw new DeviceAuthError('Timed out waiting for browser authorization. Run `vigilbase login` again.', { status: 0, code: 'timeout' }); }