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