@vigilbase/cli
Version:
Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.
286 lines (285 loc) • 13.4 kB
JavaScript
import { readIdentityTokenMetadata } from '../auth/token-metadata.js';
import { discoverAuthProvider } from '../auth/provider.js';
import { revokeIdentityCredentials, revokeIdentityGrant, revokeSavedCredentials, withCredentialsLock } from '../auth/refresh.js';
import { checkCredentialsLocation, clearIdentityCredentials, clearOneCredentials, hasIgnoredIdentityCredentials, readReplacedIdentityGrant, readStoredCredentials } from '../auth/store.js';
import { getOneAccessToken, readCredentials, requestDeviceCode, waitForDeviceAuthorization, writeOneCredentials, } from '../auth/index.js';
import { VigilbaseOneClient, ONE_AUTH_REQUIRED_TOOLS } from '../lib/one-client.js';
import { VigilbaseApiError } from '../lib/client.js';
import { formatUserCode, isHeadlessEnvironment, openUrlInBrowser, resolveDeviceVerificationUrl, } from '../lib/open-url.js';
import { shouldShowProgress, startProgress } from '../lib/progress.js';
import { assertCommandFlags, ok, outputJson, outputLines, withSpinner, } from './helpers.js';
export async function readTokenFromStdin(input = process.stdin) {
if ('isTTY' in input && input.isTTY) {
throw new Error('--token-stdin requires piped input. Pipe the token from your secret manager to vigilbase login --token-stdin.');
}
let token = '';
for await (const chunk of input) {
token += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
if (Buffer.byteLength(token, 'utf8') > 16384)
throw new Error('Token input exceeds 16 KiB.');
}
token = token.trim();
if (!token || /\s/.test(token))
throw new Error('Token input must contain exactly one nonempty token.');
return token;
}
/**
* Revoke the Identity grant a new login replaces, before the new grant exists. Identity treats a revoked refresh
* token presented again as reuse and ends every CLI grant of the user, so revoking after the new grant was issued
* could end it too. The lock is held from the read to the removal of the old file, so no concurrent command can
* refresh with the token in between, and no later command presents it again.
*/
async function retirePreviousIdentityGrant(ctx) {
await withCredentialsLock(ctx.config, async () => {
const previous = await readReplacedIdentityGrant(ctx.config);
// After a refresh with an uncertain result the token may be rotated already; presenting it trips the same check.
if (!previous || previous.refreshPending)
return;
try {
await revokeIdentityGrant(ctx.config, previous);
}
catch {
ctx.writer.stderr("Warning: Couldn't revoke your previous sign-in; it will expire on its own.");
return;
}
// The grant came from identity-credentials.json, or from a CLI 0.2.0 credentials.json when that file is absent.
const { identity } = await readStoredCredentials(ctx.config, () => { });
await (identity ? clearIdentityCredentials(ctx.config) : clearOneCredentials(ctx.config));
}, true);
}
export const handleLogin = async (ctx) => {
if (ctx.rest.length)
throw new Error('Usage: vigilbase login [--token-stdin | --token <bearer>] [--no-open]');
assertCommandFlags(ctx, ['token', 'isTokenStdin', 'isNoOpen']);
if (ctx.loginHandler) {
return ctx.loginHandler(ctx.config);
}
// Fail on Windows or an unsafe credentials directory or file before validating a token or starting device
// authorization: once a device grant is approved, a failed save would leave it unsaved and unrevoked.
await checkCredentialsLocation(ctx.config);
if (ctx.values.isTokenStdin && (ctx.values.token !== undefined || ctx.env.VIGILBASE_ONE_TOKEN !== undefined)) {
throw new Error('Use --token-stdin without --token or VIGILBASE_ONE_TOKEN.');
}
const explicitToken = ctx.values.isTokenStdin
? await readTokenFromStdin(ctx.stdin)
: ctx.values.token?.trim() || ctx.env.VIGILBASE_ONE_TOKEN?.trim() || '';
if (ctx.values.token !== undefined && !ctx.values.token.trim()) {
throw new Error('The --token value must not be empty.');
}
if (explicitToken) {
const oneClient = new VigilbaseOneClient(ctx.config, explicitToken);
let user;
const session = await withSpinner(ctx, 'Validating token', () => oneClient.getSession());
if (!session.user) {
throw new Error('Token did not resolve to a Vigilbase One session.');
}
user = {
id: session.user.id,
email: session.user.email ?? undefined,
name: session.user.name ?? undefined,
};
await withCredentialsLock(ctx.config, async () => {
// Identity credentials win when tokens are read, so revoke them before saving this session.
await revokeIdentityCredentials(ctx.config);
await writeOneCredentials(ctx.config, {
accessToken: explicitToken,
tokenType: 'Bearer',
expiresAt: typeof session.session?.expiresAt === 'string'
? session.session.expiresAt
: undefined,
user,
obtainedAt: new Date().toISOString(),
});
}, true);
if (ctx.mode === 'json') {
outputJson(ctx, {
success: true,
mode: 'token',
oneBaseUrl: ctx.config.oneBaseUrl,
user: user ?? null,
authRequiredTools: [...ONE_AUTH_REQUIRED_TOOLS],
});
}
else {
ctx.writer.stdout(`signed in${user?.email ? ` ${user.email}` : ''} (token)`);
}
return ok();
}
const preferNoOpen = Boolean(ctx.values.isNoOpen) || isHeadlessEnvironment(ctx.env);
ctx.writer.stderr(`login ${ctx.config.oneBaseUrl}`);
ctx.config.authProvider = await discoverAuthProvider(ctx.config);
if (ctx.config.authProvider)
await retirePreviousIdentityGrant(ctx);
const device = await withSpinner(ctx, 'Requesting device code', () => requestDeviceCode(ctx.config));
const verifyUrl = resolveDeviceVerificationUrl(ctx.config.authProvider?.issuer ?? ctx.config.oneBaseUrl, device);
const prettyCode = formatUserCode(device.user_code);
ctx.writer.stderr(`code ${prettyCode}`);
ctx.writer.stderr(`url ${verifyUrl}`);
if (preferNoOpen) {
ctx.writer.stderr('headless approve the URL on any signed-in browser, or re-run with --token');
}
else {
const opened = openUrlInBrowser(verifyUrl);
if (opened.isOpened) {
ctx.writer.stderr('browser opened — approve the device code');
}
else {
ctx.writer.stderr(`browser not opened (${opened.reason ?? 'failed'}) — paste the URL above`);
}
}
const showSpinner = shouldShowProgress(ctx.mode);
const progress = startProgress('waiting for approval', {
isEnabled: showSpinner,
expectedMs: Math.min(Math.max(device.expires_in, 30) * 1000, 180_000),
});
if (!showSpinner && ctx.mode !== 'json') {
ctx.writer.stderr('waiting for approval…');
}
let token;
try {
token = await waitForDeviceAuthorization(ctx.config, device);
progress.stop('✓ approved');
}
catch (error) {
progress.stop('✗ login failed');
throw error;
}
const expiresAt = typeof token.expires_in === 'number'
? new Date(Date.now() + token.expires_in * 1000).toISOString()
: undefined;
let user;
try {
const oneClient = new VigilbaseOneClient(ctx.config, token.access_token);
const session = await oneClient.getSession();
if (session.user) {
user = {
id: session.user.id,
email: session.user.email ?? undefined,
name: session.user.name ?? undefined,
};
}
}
catch {
// Session enrichment is best-effort.
}
if (ctx.config.authProvider && (!token.refresh_token || !expiresAt || !token.expires_in || token.expires_in > 300))
throw new Error('Identity returned an invalid token grant.');
const identityMetadata = ctx.config.authProvider ? readIdentityTokenMetadata(token.access_token, { issuer: ctx.config.authProvider.issuer, clientId: ctx.config.clientId, resource: ctx.config.authProvider.resource }) : undefined;
await withCredentialsLock(ctx.config, async () => {
// A legacy session is saved in credentials.json, which saved Identity credentials would override.
if (!ctx.config.authProvider)
await revokeIdentityCredentials(ctx.config);
await writeOneCredentials(ctx.config, {
...(ctx.config.authProvider ? { issuer: ctx.config.authProvider.issuer, clientId: ctx.config.clientId, resource: ctx.config.authProvider.resource, refreshToken: token.refresh_token, orgId: identityMetadata?.organizationId } : {}),
accessToken: token.access_token,
tokenType: token.token_type,
scope: token.scope,
expiresAt,
user,
obtainedAt: new Date().toISOString(),
});
}, true);
if (ctx.mode === 'json') {
outputJson(ctx, {
success: true,
mode: 'device',
oneBaseUrl: ctx.config.oneBaseUrl,
expiresAt: expiresAt ?? null,
scope: token.scope ?? null,
user: user ?? null,
authRequiredTools: [...ONE_AUTH_REQUIRED_TOOLS],
});
}
else {
ctx.writer.stdout(`signed in${user?.email ? ` ${user.email}` : ''}`);
}
return ok();
};
export const handleLogout = async (ctx) => {
if (ctx.rest.length)
throw new Error('Usage: vigilbase logout');
assertCommandFlags(ctx, []);
const cleared = await revokeSavedCredentials(ctx.config, (warning) => {
if (ctx.mode !== 'json')
ctx.writer.stderr(`Warning: ${warning}`);
});
if (ctx.mode === 'json') {
outputJson(ctx, { success: true, cleared });
}
else if (cleared) {
ctx.writer.stdout('Signed out. Local One credentials removed.');
}
else {
ctx.writer.stdout('Already signed out.');
}
return ok();
};
export const handleWhoami = async (ctx) => {
if (ctx.rest.length)
throw new Error('Usage: vigilbase whoami');
assertCommandFlags(ctx, []);
const legacyIdentityFileIgnored = await hasIgnoredIdentityCredentials(ctx.config);
const accessToken = await getOneAccessToken(ctx.config);
let liveUser = null;
if (accessToken) {
try {
const session = await withSpinner(ctx, 'Loading session', () => new VigilbaseOneClient(ctx.config, accessToken, true).getSession());
if (session.user) {
liveUser = {
id: session.user.id,
email: session.user.email ?? undefined,
name: session.user.name ?? undefined,
};
}
}
catch (error) {
// A rejected session is signed out. Connectivity and server failures
// must remain errors rather than claiming the cached session is valid.
if (!(error instanceof VigilbaseApiError) || ![401, 403].includes(error.status)) {
throw error;
}
}
}
const signedIn = Boolean(liveUser);
// Read after the token and session calls: either may have refreshed the sign-in, which saves a new expiry and scope.
const credentials = await readCredentials(ctx.config);
const payload = {
authenticated: signedIn,
oneBaseUrl: ctx.config.oneBaseUrl,
issuer: credentials.one?.issuer ?? null,
clientId: credentials.one?.clientId ?? null,
resource: credentials.one?.resource ?? null,
organizationId: credentials.one?.orgId ?? null,
user: liveUser,
expiresAt: signedIn ? credentials.one?.expiresAt ?? null : null,
scope: signedIn ? credentials.one?.scope ?? null : null,
obtainedAt: signedIn ? credentials.one?.obtainedAt ?? null : null,
authRequiredTools: signedIn ? [...ONE_AUTH_REQUIRED_TOOLS] : [],
legacyIdentityFileIgnored,
};
// CLI 0.2.0 can save a second Identity grant in credentials.json; logout revokes it.
const legacyNote = 'Note: found a credentials file from CLI 0.2.0. `vigilbase logout` will revoke and remove it.';
if (ctx.mode === 'json') {
outputJson(ctx, payload);
}
else if (!signedIn) {
outputLines(ctx, [
'Not signed in. Public tools still work.',
'Run `vigilbase login` to unlock auth-required tools (website-security, react2shell, fortibleed-check).',
...(legacyIdentityFileIgnored ? [legacyNote] : []),
]);
}
else {
const lines = [`Signed in to ${ctx.config.oneBaseUrl}`];
if (liveUser?.email)
lines.push(`User: ${liveUser.email}`);
if (credentials.one?.expiresAt) {
lines.push(`Expires: ${credentials.one.expiresAt}`);
}
lines.push(`Auth-required tools: ${ONE_AUTH_REQUIRED_TOOLS.join(', ')}`);
if (legacyIdentityFileIgnored)
lines.push(legacyNote);
outputLines(ctx, lines);
}
return ok();
};