storebound/cli/shots/auth.ts

179 lines
11 KiB
TypeScript

// Signed-in web capture (shots.capture.auth). Two ways in, applied to every browser context before its first shot:
// request { request: { url, method?, headers?, body? } }: one HTTP call per context through the context's own
// request API, so the cookies it sets stay in that context. Secrets are references only:
// "${cred:NAME}" (.storebound/credentials.json: reviewDemoAccount.username / .password, the same with
// REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD from the environment, or captureAuth.NAME) or "${env:NAME}".
// A password-like key with a literal value is refused. A non-2xx/3xx status stops the capture with the
// status, never the values.
// storageState { storageState: ".storebound/auth.json" }: Playwright storage state (cookies + localStorage) saved by
// `storebound shots login`, loaded into every context. It must live in .storebound/ (gitignored).
// Resolved values are collected so every log line, error and report text can be masked (maskSecrets).
import { existsSync, readFileSync } from 'node:fs';
import { join, resolve as resolvePath } from 'node:path';
import { reviewDemoAccount } from '../lib/credentials';
export interface AuthRequest {
/** Absolute URL, or a path after shots.capture.baseUrl (e.g. "/api/login"). */
url: string;
/** Default POST. */
method?: string;
headers?: Record<string, string>;
/** An object is sent as JSON, a string as is. Secrets as "${cred:NAME}" / "${env:NAME}" only. */
body?: Record<string, unknown> | string;
}
export interface CaptureAuth {
request?: AuthRequest;
/** Playwright storage state file in .storebound/ (from `storebound shots login`). */
storageState?: string;
/** Pattern (regex source) of the login page URL; a shot that lands there warns. Default /login|signin|sign-in|auth/. */
loginUrl?: string;
}
export const DEFAULT_STATE = '.storebound/auth.json';
const REF = /\$\{(cred|env):([A-Za-z0-9_.-]+)\}/g;
const SECRET_KEY = /pass(word)?|pwd|secret|token|api[-_]?key|authorization|cookie|session|otp|pin$|credential/i;
/** Problems with shots.capture.auth (pure): shape, a gitignored state path, and literal secrets. */
export function validateAuth(a: CaptureAuth | undefined): string[] {
if (!a) return [];
const e: string[] = [];
if (!!a.request === !!a.storageState) e.push('shots.capture.auth: either { request: { url, method, headers, body } } or { storageState: ".storebound/auth.json" }');
if (a.storageState && !/^\.storebound[\\/]/.test(a.storageState)) e.push('shots.capture.auth.storageState: a file in .storebound/ (gitignored), e.g. ".storebound/auth.json"');
if (a.request) {
if (!a.request.url) e.push('shots.capture.auth.request.url: the sign-in endpoint (absolute, or a path after baseUrl)');
for (const k of literalSecrets(a.request)) e.push(`shots.capture.auth.request.${k}: a secret is never written in the config; use "\${cred:NAME}" (.storebound/credentials.json) or "\${env:NAME}"`);
}
if (a.loginUrl) try { new RegExp(a.loginUrl); } catch { e.push('shots.capture.auth.loginUrl: a regular expression'); }
return e;
}
/** Paths (headers.X, body.y) whose key looks like a secret but whose value is not only references. */
export function literalSecrets(r: AuthRequest): string[] {
const out: string[] = [];
const literal = (v: unknown): boolean => typeof v === 'string' ? v.replace(REF, '').replace(/^(Bearer|Basic|Token)\s+/i, '').trim() !== '' : v !== undefined && v !== null && typeof v !== 'object';
const walk = (o: unknown, path: string): void => {
if (!o || typeof o !== 'object') return;
for (const [k, v] of Object.entries(o as Record<string, unknown>)) {
if (v && typeof v === 'object') walk(v, `${path}.${k}`);
else if (SECRET_KEY.test(k) && literal(v)) out.push(`${path}.${k}`);
}
};
walk(r.headers, 'headers');
if (typeof r.body === 'string') {
// a form or JSON string body: check its fields the same way
let parsed: unknown = null;
try { parsed = JSON.parse(r.body); } catch { if (r.body.includes('=')) parsed = Object.fromEntries(new URLSearchParams(r.body)); }
walk(parsed, 'body');
} else walk(r.body, 'body');
return out;
}
/** Where references resolve: credentials.json (reviewDemoAccount, captureAuth) and the environment. */
export function credLookup(root: string, env: Record<string, string | undefined> = process.env): (kind: 'cred' | 'env', name: string) => string | undefined {
let store: Record<string, unknown> | null = null;
const file = (): Record<string, unknown> => {
if (store) return store;
const p = join(root, '.storebound', 'credentials.json');
try { store = existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) as Record<string, unknown> : {}; } catch { store = {}; }
return store;
};
return (kind, name) => {
if (kind === 'env') return env[name] || undefined;
if (name === 'reviewDemoAccount.username' || name === 'reviewDemoAccount.password') {
const eu = env.REVIEW_DEMO_USER, ep = env.REVIEW_DEMO_PASSWORD;
const d = eu && ep ? { username: eu, password: ep } : reviewDemoAccount(root);
return d ? (name.endsWith('username') ? d.username : d.password) : undefined;
}
const ca = file().captureAuth as Record<string, unknown> | undefined;
const v = ca?.[name.replace(/^captureAuth\./, '')];
return typeof v === 'string' && v ? v : undefined;
};
}
/** Replaces every reference; collects the values into `secrets`. A missing one throws with its NAME only. */
export function resolveRefs(text: string, lookup: ReturnType<typeof credLookup>, secrets: Set<string>): string {
return text.replace(REF, (_m, kind: 'cred' | 'env', name: string) => {
const v = lookup(kind, name);
if (v === undefined) throw new Error(kind === 'env' ? `shots.capture.auth: environment variable ${name} is not set` : `shots.capture.auth: \${cred:${name}} is not in .storebound/credentials.json (reviewDemoAccount { username, password } or captureAuth { "${name.replace(/^captureAuth\./, '')}": ... })`);
secrets.add(v);
return v;
});
}
const deepResolve = (v: unknown, f: (s: string) => string): unknown =>
typeof v === 'string' ? f(v) : Array.isArray(v) ? v.map(x => deepResolve(x, f)) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, deepResolve(x, f)])) : v;
export interface ResolvedRequest { url: string; method: string; headers: Record<string, string>; body?: string }
/** The sign-in request with references resolved (values only in memory, listed in `secrets`). */
export function resolveRequest(r: AuthRequest, baseUrl: string, lookup: ReturnType<typeof credLookup>, secrets: Set<string>): ResolvedRequest {
const f = (s: string) => resolveRefs(s, lookup, secrets);
const headers = deepResolve(r.headers ?? {}, f) as Record<string, string>;
let body: string | undefined;
if (typeof r.body === 'string') body = f(r.body);
else if (r.body) { body = JSON.stringify(deepResolve(r.body, f)); if (!Object.keys(headers).some(h => h.toLowerCase() === 'content-type')) headers['content-type'] = 'application/json'; }
const url = /^https?:\/\//.test(r.url) ? f(r.url) : baseUrl.replace(/\/$/, '') + f(r.url);
return { url, method: (r.method ?? 'POST').toUpperCase(), headers, ...(body !== undefined ? { body } : {}) };
}
/** Hides every resolved secret (and its URL-encoded form) in a text. */
export function maskSecrets(text: string, secrets: Iterable<string>): string {
let out = text;
for (const s of secrets) if (s.length >= 3) for (const form of new Set([s, encodeURIComponent(s)])) out = out.split(form).join('***');
return out;
}
/** URL for a log line: origin + path only (a query can carry a token). */
export const safeUrl = (u: string): string => { try { const x = new URL(u); return x.origin + x.pathname; } catch { return u.split('?')[0]!; } };
/** A shot that wanted `want` but shows a login page. */
export function landedOnLogin(want: string, got: string, pattern?: string): boolean {
const re = new RegExp(pattern ?? '(^|[/._-])(login|log-in|signin|sign-in|sign_in|auth)([/._?-]|$)', 'i');
try {
const w = new URL(want), g = new URL(got);
if (w.pathname === g.pathname) return false;
return re.test(g.pathname + g.search) && !re.test(w.pathname);
} catch { return false; }
}
export const expiredHint = (url: string): string => `${safeUrl(url)} is a sign-in page: session expired? run storebound shots login again`;
/* eslint-disable @typescript-eslint/no-explicit-any */
export interface AuthPlan {
/** Extra newContext options (storageState). */
contextOptions: Record<string, unknown>;
/** Signs a fresh context in (request auth); a no-op for storageState. */
prepare: (context: any) => Promise<void>;
secrets: Set<string>;
loginUrl?: string;
}
/** Builds the per-context sign-in from shots.capture.auth. Throws before any browser starts when something is missing. */
export function authPlan(a: CaptureAuth, o: { root: string; baseUrl: string; env?: Record<string, string | undefined> }): AuthPlan {
const problems = validateAuth(a);
if (problems.length) throw new Error(problems.join('\n '));
const secrets = new Set<string>();
if (a.storageState) {
const p = resolvePath(o.root, a.storageState);
if (!existsSync(p)) throw new Error(`shots.capture.auth.storageState: ${a.storageState} does not exist; run storebound shots login (sign in to a demo account by hand)`);
let state: { cookies?: { value?: string }[] };
try { state = JSON.parse(readFileSync(p, 'utf8')) as typeof state; } catch { throw new Error(`shots.capture.auth.storageState: ${a.storageState} is not a Playwright storage state; run storebound shots login again`); }
for (const c of state.cookies ?? []) if (c.value && c.value.length >= 6) secrets.add(c.value);
return { contextOptions: { storageState: p }, prepare: async () => {}, secrets, ...(a.loginUrl ? { loginUrl: a.loginUrl } : {}) };
}
const req = resolveRequest(a.request!, o.baseUrl, credLookup(o.root, o.env), secrets);
return {
contextOptions: {}, secrets, ...(a.loginUrl ? { loginUrl: a.loginUrl } : {}),
prepare: async (context: any) => {
let status = 0;
try {
const r = await context.request.fetch(req.url, { method: req.method, headers: req.headers, ...(req.body !== undefined ? { data: req.body } : {}), timeout: 30_000, maxRedirects: 0 });
status = r.status();
await r.dispose?.();
} catch (e) { throw new Error(`sign-in request ${req.method} ${safeUrl(maskSecrets(req.url, secrets))} failed: ${maskSecrets((e as Error).message.split('\n')[0]!, secrets).slice(0, 200)}`); }
if (status < 200 || status >= 400) throw new Error(`sign-in request ${req.method} ${safeUrl(maskSecrets(req.url, secrets))} returned HTTP ${status} (check the demo account in .storebound/credentials.json and shots.capture.auth.request; values not shown)`);
// the session cookies it set are masked like the request values
const cookies = await context.cookies();
for (const c of cookies) if (c.value && c.value.length >= 6) secrets.add(c.value);
},
};
}
/* eslint-enable @typescript-eslint/no-explicit-any */