@vigilbase/cli
Version:
Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.
494 lines (493 loc) • 22.4 kB
JavaScript
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;
}
}