UNPKG

@vigilbase/cli

Version:

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

241 lines (240 loc) • 14 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, NetworkError } from '../lib/network.js'; import { assertFile, checkDirectory, credentialsPath, readCredentials, readLockedCredentials, readStoredCredentials, writeOneCredentials, clearOneCredentials, clearIdentityCredentials, shellQuote, DEFAULT_ONE_ORIGIN, SignInEndedError, UNCERTAIN_REFRESH } from './store.js'; import { identityProvider, validateIdentityIssuer } from './provider.js'; import { stripAcl } from './acl.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); } /** credentials.json.lock is held: by a running command, by one that was interrupted, or it is damaged. */ export class CredentialsLockedError extends Error { constructor(message) { super(message); this.name = 'CredentialsLockedError'; } } /** An empty or unparseable lock this old is not one a command is still writing; login and logout replace it. */ const DAMAGED_LOCK_AGE_MS = 30_000; function isMissing(error) { return error instanceof Error && 'code' in error && error.code === 'ENOENT'; } /** Who holds the lock, or null when it was released in between. `pid` is null for an empty or unparseable lock. */ async function readLockOwner(path) { let info, handle; try { info = await lstat(path); assertFile(info, path); handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch (error) { if (isMissing(error)) return null; throw error; } let owner = ''; try { assertFile(await handle.stat(), path); owner = await handle.readFile('utf8'); } finally { await handle.close(); } if (!/^\s*\d+\s*$/.test(owner) || !Number.isSafeInteger(Number(owner)) || Number(owner) <= 0) return { info, pid: null, isDead: false }; const pid = Number(owner); let isDead = false; try { process.kill(pid, 0); } catch (error) { isDead = error instanceof Error && 'code' in error && error.code === 'ESRCH'; } return { info, pid, isDead }; } /** * credentials.json.lock guards both credentials files; CLI 0.2.0 takes the same lock. * Only login and logout (`replaceDeadLock`) replace a lock whose owner has exited, or an old damaged one: a crashed * refresh may have consumed the rotating token, and both start over without it. Other commands fail at once on a * dead owner. 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 owner = await readLockOwner(path); if (!owner) continue; const isOldDamaged = owner.pid === null && Date.now() - owner.info.mtimeMs > DAMAGED_LOCK_AGE_MS; if (replaceDeadLock && (owner.isDead || isOldDamaged)) { const current = await lstat(path).catch(() => null); if (current?.ino === owner.info.ino && current.dev === owner.info.dev) await rm(path); continue; } if (owner.isDead) throw new CredentialsLockedError(`A previous vigilbase command was interrupted while it held ${shellQuote(path)}. Run \`vigilbase login\` to recover.`); if (!wait || Date.now() >= deadline) { throw new CredentialsLockedError(owner.pid === null ? `${path} is damaged. Stop every vigilbase command, delete it, and run \`vigilbase login\`.` : `Credentials are locked by another vigilbase command (process ${owner.pid}). If none is running, delete ${shellQuote(path)} and run \`vigilbase login\`.`); } await new Promise(resolve => setTimeout(resolve, 100)); } } await handle.chmod(0o600); // Best effort: the lock holds only a PID, and a failure here must not leave it behind. await stripAcl(path).catch(() => { }); 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 }); let response, text; try { ({ response, text } = await requestText(config, provider.tokenEndpoint, { option: '--auth-url', 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 }) })); } catch (error) { // The request may have reached Identity and rotated the token, so it stays pending and can't be retried. const reason = error instanceof NetworkError ? ` (${error.message.split(/\.(?:\s|$)/)[0]})` : ''; throw new Error(`The Identity refresh may not have completed${reason}. Run \`vigilbase login\` again; its token cannot be retried safely.`, { cause: error }); } const body = parseJsonResponse(response, text); if (!response.ok) { if (z.object({ error: z.literal('invalid_grant') }).safeParse(body).success) { await clearIdentityCredentials(config); throw new SignInEndedError(); } // A definite client error means the token was not used. A server error may come after it rotated. if (response.status < 500) await writeOneCredentials(config, { ...one, refreshPending: false }); 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, { option: '--auth-url', 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`, { option: '--one-url', 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) { // Nothing is saved without a credentials directory; don't create one to take the lock. if (!await checkDirectory(dirname(credentialsPath(config)))) return false; 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); }