@vigilbase/cli
Version:
Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.
240 lines (239 loc) • 10.3 kB
JavaScript
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' });
}