UNPKG

@vigilbase/cli

Version:

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

286 lines (285 loc) • 13.4 kB
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(); };