UNPKG

@vigilbase/cli

Version:

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

200 lines (199 loc) • 11.6 kB
import { readIdentityTokenMetadata } from './token-metadata.js'; import { constants } from 'node:fs'; import { open, rm, lstat } from 'node:fs/promises'; import { dirname } from 'node:path'; import { z } from 'zod'; import { DEFAULT_TIMEOUT_MS, validateOneOrigin } from '../lib/config.js'; import { requestText, parseJsonResponse } from '../lib/network.js'; import { assertFile, checkDirectory, credentialsPath, readCredentials, readLockedCredentials, readStoredCredentials, writeOneCredentials, clearOneCredentials, clearIdentityCredentials, DEFAULT_ONE_ORIGIN, UNCERTAIN_REFRESH } from './store.js'; import { identityProvider, validateIdentityIssuer } from './provider.js'; export function boundIdentityCredentials(config, one) { try { return one.clientId === config.clientId && one.resource === validateOneOrigin(config.oneBaseUrl) && one.oneBaseUrl === one.resource && validateIdentityIssuer(one.issuer, config) === one.issuer; } catch { return false; } } const MAX_LOCK_WAIT_MS = 15_000; /** Library callers may omit or mistype timeoutMs; NaN would make the wait unbounded. */ export function credentialsLockWaitMs(timeoutMs) { const requested = typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS; return Math.min(requested, MAX_LOCK_WAIT_MS); } /** Another command holds credentials.json.lock. */ export class CredentialsLockedError extends Error { constructor() { super('Credentials are locked by another command. A crashed refresh requires a new login; do not reuse its refresh token.'); this.name = 'CredentialsLockedError'; } } /** * credentials.json.lock guards both credentials files; CLI 0.2.0 takes the same lock. * Never steal a stale lock: a crashed refresh may have consumed the rotating token. * With `wait` false, a held lock fails at once instead of after the lock wait. */ export async function withCredentialsLock(config, callback, replaceDeadLock = false, wait = true) { const path = `${credentialsPath(config)}.lock`; await checkDirectory(dirname(path), true); const deadline = Date.now() + credentialsLockWaitMs(config.timeoutMs); let handle; for (;;) { try { handle = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); break; } catch (error) { if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error; const existing = await lstat(path); assertFile(existing, path); if (replaceDeadLock) { const existingHandle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); let owner = ''; try { assertFile(await existingHandle.stat(), path); owner = await existingHandle.readFile('utf8'); } finally { await existingHandle.close(); } const pid = Number(owner); let dead = false; if (Number.isSafeInteger(pid) && pid > 0) { try { process.kill(pid, 0); } catch (error) { dead = error instanceof Error && 'code' in error && error.code === 'ESRCH'; } } if (dead) { const current = await lstat(path); if (current.ino === existing.ino && current.dev === existing.dev) { await rm(path); continue; } } } if (!wait || Date.now() >= deadline) throw new CredentialsLockedError(); await new Promise(resolve => setTimeout(resolve, 100)); } } await handle.chmod(0o600); const info = await handle.stat(); assertFile(info, path); try { await handle.writeFile(String(process.pid)); await handle.sync(); return await callback(); } finally { await handle.close(); const current = await lstat(path).catch(() => null); if (current?.ino === info.ino && current.dev === info.dev) await rm(path); } } export async function managedAccessToken(config, failedToken) { const initial = await readCredentials(config); if (initial.version !== 2 || !initial.one || !boundIdentityCredentials(config, initial.one)) return null; if (!failedToken && Date.parse(initial.one.expiresAt ?? '') - Date.now() > 60_000) { readIdentityTokenMetadata(initial.one.accessToken, { issuer: initial.one.issuer, clientId: initial.one.clientId, resource: initial.one.resource }); return initial.one.accessToken; } return withCredentialsLock(config, async () => { const current = await readLockedCredentials(config), one = current.one; if (current.version !== 2 || !one || !boundIdentityCredentials(config, one)) return null; if (failedToken && one.accessToken !== failedToken) { readIdentityTokenMetadata(one.accessToken, { issuer: one.issuer, clientId: one.clientId, resource: one.resource }); return one.accessToken; } if (!failedToken && Date.parse(one.expiresAt ?? '') - Date.now() > 60_000) { readIdentityTokenMetadata(one.accessToken, { issuer: one.issuer, clientId: one.clientId, resource: one.resource }); return one.accessToken; } if (one.refreshPending) throw new Error(UNCERTAIN_REFRESH); const provider = await identityProvider(config, one.issuer); await writeOneCredentials(config, { ...one, refreshPending: true }); const { response, text } = await requestText(config, provider.tokenEndpoint, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': config.userAgent }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: one.refreshToken, client_id: one.clientId, resource: one.resource }) }); const body = parseJsonResponse(response, text); if (!response.ok) { if (z.object({ error: z.literal('invalid_grant') }).safeParse(body).success) await clearIdentityCredentials(config); throw new Error('Identity refresh failed. Run `vigilbase login` again.'); } const token = z.object({ access_token: z.string().min(1), refresh_token: z.string().min(1), expires_in: z.number().int().positive().max(300), scope: z.string().optional() }).safeParse(body); if (!token.success) throw new Error('Invalid Identity refresh response.'); const metadata = readIdentityTokenMetadata(token.data.access_token, { issuer: one.issuer, clientId: one.clientId, resource: one.resource }); if (one.orgId && metadata.organizationId !== one.orgId) throw new Error('Identity refresh changed its bound organization. Run `vigilbase login` again.'); await writeOneCredentials(config, { ...one, orgId: metadata.organizationId, refreshPending: false, accessToken: token.data.access_token, refreshToken: token.data.refresh_token, expiresAt: new Date(Date.now() + token.data.expires_in * 1000).toISOString(), scope: token.data.scope ?? one.scope }); return token.data.access_token; }); } const SAVED_FOR_OTHER_CONFIGURATION = 'Saved credentials belong to a different issuer, resource, or client. Use the matching configuration to log out.'; /** * Revoke one saved refresh grant. Identity answers 400 (`invalid_request` or `invalid_grant`) for a token it no * longer knows or has already revoked, for example after a browser sign-out ended the session that approved it, * or a refresh rotated it; nothing is left to revoke then. Other failures throw. */ export async function revokeIdentityGrant(config, one) { if (!boundIdentityCredentials(config, one)) throw new Error(SAVED_FOR_OTHER_CONFIGURATION); const provider = await identityProvider(config, one.issuer); const { response, text } = await requestText(config, provider.revocationEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': config.userAgent }, body: new URLSearchParams({ token: one.refreshToken, token_type_hint: 'refresh_token', client_id: one.clientId }) }); if (response.ok) return; if (response.status === 400 && z.object({ error: z.enum(['invalid_request', 'invalid_grant']) }).safeParse(parseJsonResponse(response, text)).success) return; throw new Error('Identity revocation failed, so your saved sign-in was kept. Try again later.'); } async function revokeIdentityGrants(config, { identity, shared }) { const grants = [identity, shared].flatMap((saved) => saved?.version === 2 && saved.one ? [saved.one] : []); // Check every grant first, so a mismatch can't leave one revoked while its file stays. if (grants.some((one) => !boundIdentityCredentials(config, one))) throw new Error(SAVED_FOR_OTHER_CONFIGURATION); for (const one of grants) await revokeIdentityGrant(config, one); } /** * Revoke every saved Identity grant, then remove its file. A failed revocation keeps every file. A safe file that * can't be parsed has no grant to revoke; the login that calls this replaces it. Callers hold the lock. */ export async function revokeIdentityCredentials(config) { const stored = await readStoredCredentials(config, () => { }); await revokeIdentityGrants(config, stored); // credentials.json goes too only when it held a CLI 0.2.0 grant. return stored.shared?.version === 2 ? clearOneCredentials(config) : clearIdentityCredentials(config); } /** Best effort: legacy One auth retires on 2026-10-31, and its session expires on its own. */ async function signOutLegacySession(config, one) { const origin = validateOneOrigin(one.oneBaseUrl ?? DEFAULT_ONE_ORIGIN); const { response } = await requestText(config, `${origin}/api/auth/sign-out`, { method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${one.accessToken}`, 'User-Agent': config.userAgent }, redirect: 'manual', credentials: 'omit' }); if (!response.ok) throw new Error('Legacy One sign-out failed.'); } /** Revoke and remove every credentials file. A failed Identity revocation keeps them all. */ export async function revokeSavedCredentials(config, onWarning) { return withCredentialsLock(config, async () => { // A safe file that cannot be parsed cannot be revoked either; removing it keeps logout from dead-ending. const unreadable = []; const stored = await readStoredCredentials(config, (path) => unreadable.push(path)); await revokeIdentityGrants(config, stored); if (stored.shared?.version === 1 && stored.shared.one) { try { await signOutLegacySession(config, stored.shared.one); } catch { onWarning?.('Could not sign out the legacy One session. Local credentials are removed anyway.'); } } const cleared = await clearOneCredentials(config); if (unreadable.length) onWarning?.(`Couldn't read the saved sign-in in ${unreadable.join(' and ')}, so it was removed without revoking it. It will expire on its own.`); return cleared; }, true); }