UNPKG

@vigilbase/cli

Version:

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

494 lines (493 loc) • 22.4 kB
import { randomUUID } from 'node:crypto'; import { constants } from 'node:fs'; import { lstat, mkdir, open, rename, rm } from 'node:fs/promises'; import { basename, dirname, isAbsolute, join, parse, resolve } from 'node:path'; import { z } from 'zod'; import { DEFAULT_TIMEOUT_MS } from '../lib/config.js'; import { platform } from '../lib/platform.js'; const FILE_NAME = 'credentials.json'; /** CLI 0.1.x rejects a version 2 credentials.json and deletes it unrevoked, so Identity grants live apart. */ const IDENTITY_FILE_NAME = 'identity-credentials.json'; const MODE_DIR = 0o700; const MODE_FILE = 0o600; export const DEFAULT_ONE_ORIGIN = 'https://one.vigilbase.com'; const INVALID_ONE_CREDENTIALS = 'Saved One credentials are invalid. Run `vigilbase logout`, then `vigilbase login` to replace them.'; const INVALID_IDENTITY_CREDENTIALS = 'Saved Identity credentials are invalid. Run `vigilbase login` to replace them.'; export const UNCERTAIN_REFRESH = 'A previous refresh has an uncertain result. Run `vigilbase login` again; its token cannot be retried safely.'; const SAVED_FOR_ANOTHER_ORIGIN = 'Saved for a different One origin, Identity issuer or client ID.'; export const OneCredentialsSchema = z.object({ issuer: z.string().optional(), clientId: z.string().optional(), resource: z.string().optional(), refreshToken: z.string().min(1).optional(), refreshTokenExpiresAt: z.string().optional(), refreshPending: z.boolean().optional(), orgId: z.string().optional(), oneBaseUrl: z.string().optional(), accessToken: z.string().min(1), tokenType: z.string().optional(), scope: z.string().optional(), expiresAt: z.string().optional(), obtainedAt: z.string(), user: z.object({ id: z.string().optional(), email: z.string().optional(), name: z.string().optional() }).optional(), }); const CredentialsSchema = z.object({ version: z.union([z.literal(1), z.literal(2)]), one: OneCredentialsSchema.nullable().optional() }); function configFilePath(config, name) { if (!isAbsolute(config.configDir)) throw relativeDirectoryError(config.configDir); return join(resolve(config.configDir), name); } /** Version 1 One session credentials, shared with CLI 0.1.x. */ export function credentialsPath(config) { return configFilePath(config, FILE_NAME); } /** Version 2 Identity credentials with the rotating refresh grant. */ export function identityCredentialsPath(config) { return configFilePath(config, IDENTITY_FILE_NAME); } function isMissing(error) { return error instanceof Error && 'code' in error && error.code === 'ENOENT'; } function isOwnedByOtherUser(info) { return typeof process.getuid === 'function' && info.uid !== process.getuid(); } /** A credentials file that passed the safety checks but cannot be parsed or is incomplete. */ export class InvalidCredentialsError extends Error { constructor(message) { super(message); this.name = 'InvalidCredentialsError'; } } /** Credentials cannot be protected by modes and owners on Windows yet. */ export class SignInUnsupportedError extends Error { code = 'SIGN_IN_UNSUPPORTED'; constructor() { super("Signing in isn't supported on Windows yet; public tools and cloudflare-checkup still work."); this.name = 'SignInUnsupportedError'; } } /** * A credentials directory or file the CLI refuses to use. `reason` names the path that failed and `remedy` * says how to fix it; `vigilbase login` can't, because it runs the same checks. */ export class CredentialsPathError extends Error { reason; remedy; constructor(reason, remedy, options) { super(`${reason}. ${remedy}`, options); this.reason = reason; this.remedy = remedy; this.name = 'CredentialsPathError'; } } const REMOVE_FILE = 'Remove it, or set VIGILBASE_CONFIG_DIR to another directory.'; export function assertFile(info, path) { if (info.isSymbolicLink()) throw new CredentialsPathError(`${path} is a symbolic link`, REMOVE_FILE); if (!info.isFile()) throw new CredentialsPathError(`${path} is not a regular file`, REMOVE_FILE); if (info.nlink !== 1) throw new CredentialsPathError(`${path} has more than one hard link`, REMOVE_FILE); if (isOwnedByOtherUser(info)) throw new CredentialsPathError(`${path} is owned by another user`, REMOVE_FILE); if ((info.mode & 0o777) !== MODE_FILE) throw new CredentialsPathError(`${path} must have mode 0600`, `Run chmod 600 ${path}.`); } const USE_ANOTHER_DIRECTORY = 'Set VIGILBASE_CONFIG_DIR to a private directory without symbolic links in its path.'; function relativeDirectoryError(configDir) { return new CredentialsPathError(`${configDir} is not an absolute path`, 'Set VIGILBASE_CONFIG_DIR to an absolute path.'); } /** * Check every ancestor before traversing it; a writable non-sticky parent permits replacement. * Every credentials read or write starts here, so on Windows they fail before touching the filesystem. */ export async function checkDirectory(configDir, isCreate = false) { if (platform() === 'win32') throw new SignInUnsupportedError(); if (!isAbsolute(configDir)) throw relativeDirectoryError(configDir); const absolute = resolve(configDir); const { root } = parse(absolute); if (absolute === root) throw new CredentialsPathError('The filesystem root cannot be the credentials directory', USE_ANOTHER_DIRECTORY); // Each ancestor below the root, ending with the credentials directory itself. const paths = []; for (let path = absolute; path !== root; path = dirname(path)) paths.unshift(path); for (const [index, path] of paths.entries()) { let info; try { info = await lstat(path); } catch (error) { if (!isMissing(error)) { const code = error instanceof Error && 'code' in error ? ` (${String(error.code)})` : ''; throw new CredentialsPathError(`${path} cannot be checked${code}`, USE_ANOTHER_DIRECTORY, { cause: error }); } if (!isCreate) return false; await mkdir(path, { mode: MODE_DIR }); info = await lstat(path); } if (info.isSymbolicLink()) throw new CredentialsPathError(`${path} is a symbolic link`, USE_ANOTHER_DIRECTORY); if (!info.isDirectory()) throw new CredentialsPathError(`${path} is not a directory`, USE_ANOTHER_DIRECTORY); if (index === paths.length - 1) { if (isOwnedByOtherUser(info)) throw new CredentialsPathError(`${path} is owned by another user`, USE_ANOTHER_DIRECTORY); if ((info.mode & 0o777) !== MODE_DIR) { throw new CredentialsPathError(`${path} must have mode 0700`, `Run chmod 700 ${path}, or set VIGILBASE_CONFIG_DIR to another directory.`); } } else { if ((info.mode & 0o022) !== 0 && (info.mode & 0o1000) === 0) throw new CredentialsPathError(`${path} is writable by other users`, USE_ANOTHER_DIRECTORY); if (isOwnedByOtherUser(info) && info.uid !== 0) throw new CredentialsPathError(`${path} is owned by another user`, USE_ANOTHER_DIRECTORY); } } return true; } async function checkExistingFile(path) { try { assertFile(await lstat(path), path); return true; } catch (error) { if (isMissing(error)) return false; throw error; } } /** Parse one credentials file; the Identity file must hold a complete version 2 grant. */ async function readCredentialsFile(path) { if (!await checkExistingFile(path)) return null; const isIdentityFile = basename(path) === IDENTITY_FILE_NAME; const invalid = () => new InvalidCredentialsError(isIdentityFile ? INVALID_IDENTITY_CREDENTIALS : INVALID_ONE_CREDENTIALS); const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); try { assertFile(await handle.stat(), path); const parsed = CredentialsSchema.safeParse(JSON.parse(await handle.readFile('utf8'))); if (!parsed.success) throw invalid(); // Keep legacy session credentials separate from rotating Identity grants. if (parsed.data.version === 1 && parsed.data.one) delete parsed.data.one.refreshToken; if ((parsed.data.version === 2 || isIdentityFile) && (parsed.data.version !== 2 || !parsed.data.one?.issuer || !parsed.data.one.clientId || !parsed.data.one.resource || !parsed.data.one.refreshToken)) throw new InvalidCredentialsError(INVALID_IDENTITY_CREDENTIALS); return parsed.data; } catch (error) { if (error instanceof SyntaxError) throw invalid(); throw error; } finally { await handle.close(); } } /** * With `onInvalid`, a safe file that cannot be parsed reads as absent and is reported; unsafe files always throw. * Without it, an unparseable credentials.json reads as absent when identity-credentials.json holds a sign-in, which wins anyway. */ export async function readStoredCredentials(config, onInvalid) { if (!await checkDirectory(dirname(credentialsPath(config)))) return { identity: null, shared: null }; const read = async (path) => { try { return await readCredentialsFile(path); } catch (error) { if (!onInvalid || !(error instanceof InvalidCredentialsError)) throw error; onInvalid(path); return null; } }; // Read credentials.json first: a concurrent migration renames it to identity-credentials.json. let shared = null; let invalidShared = null; try { shared = await read(credentialsPath(config)); } catch (error) { if (!(error instanceof InvalidCredentialsError)) throw error; invalidShared = error; } const identity = await read(identityCredentialsPath(config)); if (invalidShared && !identity) throw invalidShared; return { identity, shared }; } /** Identity credentials win over credentials.json everywhere tokens are read. */ function currentCredentials({ identity, shared }) { return identity ?? shared ?? { version: 1, one: null }; } /** * CLI 0.2.0 kept its Identity grant in credentials.json, which CLI 0.1.x rejects. * Move it to identity-credentials.json unless that file exists. Callers hold credentials.json.lock. */ async function migrateIdentityCredentials(config) { const path = credentialsPath(config); const identityPath = identityCredentialsPath(config); if (!await checkDirectory(dirname(path)) || await checkExistingFile(identityPath)) return; // An unparseable credentials.json holds no grant to move. const shared = await readCredentialsFile(path).catch((error) => { if (error instanceof InvalidCredentialsError) return null; throw error; }); if (shared?.version !== 2) return; // Re-check both paths immediately before the rename, as the write path does. await checkDirectory(dirname(path)); if (!await checkExistingFile(path) || await checkExistingFile(identityPath)) return; await rename(path, identityPath); } /** Identity credentials win. The first read moves a CLI 0.2.0 credentials.json aside under the lock. */ export async function readCredentials(config) { const stored = await readStoredCredentials(config); if (stored.identity || stored.shared?.version !== 2) return currentCredentials(stored); const { withCredentialsLock } = await import('./refresh.js'); return withCredentialsLock(config, () => readLockedCredentials(config)); } /** readCredentials that never waits: while another command holds the lock, a CLI 0.2.0 credentials.json is read in place. */ async function readCredentialsWithoutWaiting(config) { const stored = await readStoredCredentials(config); if (stored.identity || stored.shared?.version !== 2) return currentCredentials(stored); const { CredentialsLockedError, withCredentialsLock } = await import('./refresh.js'); try { return await withCredentialsLock(config, () => readLockedCredentials(config), false, false); } catch (error) { if (error instanceof CredentialsLockedError) return currentCredentials(stored); throw error; } } /** readCredentials for callers that already hold credentials.json.lock. */ export async function readLockedCredentials(config) { await migrateIdentityCredentials(config); return currentCredentials(await readStoredCredentials(config)); } /** * The Identity grant that saving a new one replaces: identity-credentials.json's, or that of a CLI 0.2.0 * credentials.json which saving moves there. An unparseable file has no grant to revoke. Callers hold the lock. */ export async function readReplacedIdentityGrant(config) { if (!await checkDirectory(dirname(credentialsPath(config)))) return null; for (const path of [identityCredentialsPath(config), credentialsPath(config)]) { let saved; try { saved = await readCredentialsFile(path); } catch (error) { if (error instanceof InvalidCredentialsError) return null; throw error; } if (saved) return saved.version === 2 ? saved.one ?? null : null; } return null; } /** * Fail before a login starts, rather than after it was approved, when saving would fail: Windows, or an unsafe * credentials directory or file. Unparseable files pass; login replaces them. */ export async function checkCredentialsLocation(config) { if (!await checkDirectory(dirname(credentialsPath(config)))) return; for (const path of [credentialsPath(config), identityCredentialsPath(config)]) await checkExistingFile(path); } /** CLI 0.2.0 can write credentials.json again after migration; that grant is ignored until logout revokes it. */ export async function hasIgnoredIdentityCredentials(config) { const { identity, shared } = await readStoredCredentials(config); return Boolean(identity && shared?.version === 2); } export async function writeOneCredentials(config, one) { // Version 2 stores the rotating refresh grant in its own file; credentials.json stays version 1. const version = one.issuer ? 2 : 1; const path = version === 2 ? identityCredentialsPath(config) : credentialsPath(config); await checkDirectory(dirname(path), true); // A new Identity grant also replaces one CLI 0.2.0 left in credentials.json. if (version === 2) await migrateIdentityCredentials(config); await checkExistingFile(path); const parsedOne = OneCredentialsSchema.parse({ ...one, oneBaseUrl: new URL(config.oneBaseUrl ?? DEFAULT_ONE_ORIGIN).origin }); if (!one.issuer) delete parsedOne.refreshToken; const next = { version, one: parsedOne }; const temporaryPath = `${path}.${randomUUID()}.tmp`; const handle = await open(temporaryPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, MODE_FILE); try { await handle.chmod(MODE_FILE); await handle.writeFile(`${JSON.stringify(next, null, 2)}\n`, 'utf8'); await handle.sync(); await handle.close(); await checkDirectory(dirname(path)); await checkExistingFile(path); await rename(temporaryPath, path); } finally { await handle.close().catch(() => { }); await rm(temporaryPath, { force: true }); } } /** Remove every local credentials file without revoking anything. */ export async function clearOneCredentials(config) { const paths = [credentialsPath(config), identityCredentialsPath(config)]; if (!await checkDirectory(dirname(paths[0]))) return false; // Check both files before removing either, so an unsafe file leaves both in place. const existing = []; for (const path of paths) if (await checkExistingFile(path)) existing.push(path); for (const path of existing) await rm(path); return existing.length > 0; } /** Remove identity-credentials.json only; credentials.json stays for CLI 0.1.x. */ export async function clearIdentityCredentials(config) { const path = identityCredentialsPath(config); if (!await checkDirectory(dirname(path)) || !await checkExistingFile(path)) return false; await rm(path); return true; } /** Saved tokens are only sent to the One origin that issued them. */ function isForOneOrigin(one, config) { try { return new URL(one.oneBaseUrl ?? DEFAULT_ONE_ORIGIN).origin === new URL(config.oneBaseUrl ?? DEFAULT_ONE_ORIGIN).origin; } catch { return false; } } function hasExpired(one) { if (one.expiresAt === undefined) return false; const expires = Date.parse(one.expiresAt); return !Number.isFinite(expires) || expires <= Date.now(); } export async function getOneAccessToken(config) { const { version, one } = await readCredentials(config); if (!one?.accessToken.trim() || !isForOneOrigin(one, config)) return null; if (version === 2) { const { managedAccessToken } = await import('./refresh.js'); // Library callers may pass a partial config; never forward an undefined timeout. const timeoutMs = typeof config.timeoutMs === 'number' && Number.isFinite(config.timeoutMs) && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS; return managedAccessToken({ ...config, timeoutMs }); } return hasExpired(one) ? null : one.accessToken; } let hasWarnedAboutSignIn = false; function isAbsent(error) { return error instanceof Error && 'code' in error && (error.code === 'ENOENT' || error.code === 'ENOTDIR'); } /** lstat() only, never open(): whether a credentials file exists, or may exist, in a directory that failed the checks. */ async function mayHaveCredentials(config) { // The CLI never saves credentials under a relative path. if (!isAbsolute(config.configDir)) return false; for (const path of [credentialsPath, identityCredentialsPath]) { try { await lstat(path(config)); return true; } catch (error) { if (!isAbsent(error)) return true; } } return false; } function messageOf(error) { return error instanceof Error ? error.message : String(error); } /** * One stderr line per process. The reason is the first sentence of the problem, and the fix is * `vigilbase login`, except for an unsafe credentials path, whose own remedy is kept: login would stop on it too. */ function warnOnce(warn, error) { if (hasWarnedAboutSignIn) return; hasWarnedAboutSignIn = true; if (error instanceof CredentialsPathError) { warn(`Couldn't use your saved sign-in (${error.reason}); running anonymously. ${error.remedy}`); return; } const problem = messageOf(error); const reason = problem.split(/\.(?:\s|$)/)[0] || problem; warn(`Couldn't use your saved sign-in (${reason}); running anonymously. Run \`vigilbase login\` to fix.`); } /** * The soft directory check for list and doctor: whether the credentials directory exists and passes the checks, * and the error to report when it doesn't. A directory that fails them is never written to or opened, and is * only a problem when a credentials file may exist there. */ async function checkDirectoryQuietly(config) { try { return { isUsable: await checkDirectory(dirname(credentialsPath(config))), problem: null }; } catch (error) { return { isUsable: false, problem: await mayHaveCredentials(config) ? error : null }; } } /** * getOneAccessToken for doctor: any problem with the saved sign-in is returned, not thrown. * Lock waits, discovery and refresh use the configured timeout. Windows skips the credential store entirely. * With no saved sign-in there is no problem, even under a config path that fails the directory checks. */ export async function getOptionalOneAccessToken(config) { if (platform() === 'win32') return { accessToken: null, problem: null }; const directory = await checkDirectoryQuietly(config); if (!directory.isUsable) return { accessToken: null, problem: directory.problem === null ? null : messageOf(directory.problem) }; try { return { accessToken: await getOneAccessToken(config), problem: null }; } catch (error) { return { accessToken: null, problem: messageOf(error) }; } } /** * Whether list can show the saved sign-in as usable, judged from the files alone: no refresh, no network * and no wait for the credentials lock. A legacy token must be unexpired; an Identity grant may hold an * expired access token, which the next One command refreshes. Windows skips the credential store entirely. * Any problem means signed out and one `warn` per process; an expired legacy token is not a problem. * A credentials directory that fails the strict checks is never written to or opened, and is only warned * about when a credentials file may exist there. Never throws. */ export async function hasUsableSignIn(config, warn) { if (platform() === 'win32') return false; const directory = await checkDirectoryQuietly(config); if (!directory.isUsable) { if (directory.problem !== null) warnOnce(warn, directory.problem); return false; } try { const { version, one } = await readCredentialsWithoutWaiting(config); if (!one?.accessToken.trim()) return false; if (version === 2) { const { boundIdentityCredentials } = await import('./refresh.js'); if (!boundIdentityCredentials(config, one)) throw new Error(SAVED_FOR_ANOTHER_ORIGIN); if (one.refreshPending) throw new Error(UNCERTAIN_REFRESH); return true; } if (!isForOneOrigin(one, config)) throw new Error(SAVED_FOR_ANOTHER_ORIGIN); return !hasExpired(one); } catch (error) { warnOnce(warn, error); return false; } }