179 lines
11 KiB
TypeScript
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 */
|