Files
HealthLog/src/__tests__/step-up-elevation-guard.test.ts
T
Marc-André Bombeck 3e8846929c test(guards): load no module graph inside a guard's test body
step-up-elevation-guard timed out under load in three separate gates
and passed every time on its own. One of its tests imported
@/lib/auth/step-up inside the test body to read the fresh-factor set,
and that module reaches the database client and the generated Prisma
client: the import alone ran against the test timeout, and how long it
took depended on the machine, not on the code under test.

The fresh-factor definitions (StepUpMethod, FRESH_FACTOR_METHODS,
isFreshFactorMethod) move to step-up-factors.ts, which imports nothing;
the guard imports it statically and the test is synchronous. Two more
guards did the same: next-image-optimizer-guard loaded next.config and
its plugins per test, and proxy-relative-redirect-guard loaded the
redirect helper with next/server. Both import statically now.

guard-dynamic-import-guard.test.ts parses every file of the guard
project with the TypeScript parser and fails on any import() call, so
a guard that needs a value imports it with the file or reads the
source; a test that must load late belongs to the isolated project.
2026-10-23 22:51:51 +02:00

415 lines
16 KiB
TypeScript

/**
* Structural guards on the step-up elevation boundary.
*
* An elevation lets a Bearer token reach a surface that is otherwise
* cookie-only. That is a deliberate, narrow widening, and its entire safety
* argument rests on the set of routes it can unlock being small, known, and
* unable to grow by accident. Behavioural tests prove the mechanism works;
* these prove nobody quietly pointed it somewhere else.
*
* MATCHING IS ON THE IMPORT, NOT THE CALL. An earlier revision grepped for
* `requireMfaManagementAuth(` and was evadable in one line:
*
* import { requireMfaManagementAuth as gate } from "@/lib/api-handler";
* const auth = await gate();
*
* A call-shaped regex sees nothing there. An import-shaped one cannot be dodged
* that way, because the binding has to be imported from the module under some
* local name before it can be called at all. The helpers below parse the import
* specifier — including its `as` alias — and check calls against the LOCAL name,
* so a rename is followed rather than lost.
*
* They remain tripwires, not proofs. They cannot show an allowlist is correct,
* only that it has not changed without someone editing this file.
*/
import { describe, it, expect } from "vitest";
import { join } from "node:path";
import { guardModuleOf, guardSourceOf } from "./helpers/auth-guards";
import {
readSource,
readSourceText,
walkSourceFiles,
} from "./helpers/source-files";
import {
FRESH_FACTOR_METHODS,
isFreshFactorMethod,
} from "@/lib/auth/step-up-factors";
const SRC = join(process.cwd(), "src");
function sourceFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.sort();
}
function read(rel: string): string {
return readSourceText(join(SRC, rel));
}
/** Local names bound for `exportName` from any module matching `moduleRe`. */
function importedAs(
rel: string,
exportName: string,
moduleRe: RegExp,
): string[] {
const src = read(rel);
const names: string[] = [];
for (const m of src.matchAll(
/import\s*\{([\s\S]*?)\}\s*from\s*["']([^"']+)["']/g,
)) {
const [, clause, specifier] = m;
if (!moduleRe.test(specifier)) continue;
for (const part of clause.split(",")) {
const piece = part.trim().replace(/^type\s+/, "");
if (!piece) continue;
const aliased = piece.match(
/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/,
);
if (aliased) {
if (aliased[1] === exportName) names.push(aliased[2]);
} else if (piece === exportName) {
names.push(piece);
}
}
}
return names;
}
const API_HANDLER = /api-handler$/;
const STEP_UP_MODULE = /auth\/step-up$/;
/**
* The guard module that reads the elevation header, validates and claims:
* the one redemption path. Found by the function that does it, so a move
* between guard modules repoints every leg below at once.
*/
const GATE_MODULE = guardModuleOf("resolveBearerElevation");
/**
* An exported declaration's source, from its signature to the next top-level
* export (or the end of its module), read from the guard module that declares
* `name`. Throws when the signature is gone, so a negative assertion on the
* slice cannot pass over an empty string.
*/
function exportedBlock(name: string, signature: string): string {
const src = guardSourceOf(name, [signature]);
const start = src.indexOf(signature);
const next = src.indexOf("\nexport ", start + 1);
const body = src.slice(start, next === -1 ? undefined : next);
expect(body.length).toBeGreaterThan(signature.length);
return body;
}
/** Files that import `exportName` from the module AND call it. */
function callers(exportName: string, moduleRe: RegExp): string[] {
return sourceFiles()
.filter((rel) => {
const locals = importedAs(rel, exportName, moduleRe);
if (locals.length === 0) return false;
const src = read(rel);
return locals.some((name) => new RegExp(`\\b${name}\\s*\\(`).test(src));
})
.sort();
}
/**
* Every route an elevation can unlock: the second-factor management MUTATIONS,
* plus the one credential removal that is not a second factor and belongs here
* anyway.
*
* That one is `DELETE /api/auth/passkeys/{id}`. A passkey is the PRIMARY
* sign-in credential, and it sat behind a plain session while the second-factor
* key beside it demanded a fresh proof — the softer gate on the more valuable
* credential. It joins the set on the same `freshFactor: true` as the rest,
* differing only in `proofSource: "any-possession"` — which widens the
* REACHABILITY pre-check to count a primary passkey and does not soften the gate
* itself. A passkey-only account clears it by re-proving that passkey: on Bearer
* through the mint's `passkey` method, on the web through a fresh sign-in, which
* is what stamps `mfaVerifiedAt`.
*
* The widening that admits is real and bounded: an elevation minted against a
* re-proved factor can now also remove a passkey. That is the same class of act
* as the rest of the list — a credential the caller owns, removed after they
* prove they are the caller — and it is written down here rather than inferred,
* which is the whole point of this file.
*
* What is deliberately ABSENT is as much the point as what is present:
* - `GET /api/auth/me/mfa`. The status read is plain `requireAuth()`: it
* carries no credential material (no secret, no code, no public key, no
* credential id — only names, dates, and a count), the web shows the same
* screen to any authenticated session, and `/api/auth/me` already exposes
* `totpConfirmedAt` over Bearer. Gating it would force a factor re-proof to
* render a screen and buy nothing;
* - every `/api/admin/*` route. `requireAdmin` is cookie-only and does not
* consult this mechanism at all;
* - the trusted-device routes. "Remember this browser" is a browser concept
* with no native equivalent to manage;
* - `requireFreshMfaIfEnrolled`'s callers (password change, encryption-key
* rotation, the sharing invite). Those are destructive account actions,
* not second-factor management, and letting an elevation satisfy them
* would be exactly the silent widening this file exists to prevent.
*
* The two whole-record exports reach the elevation through a third gate,
* `requireRecentProof`, whose route list and per-route Bearer rule are frozen
* in `recent-proof-surface-guard.test.ts`.
*
* Account deletion and the record wipe used to sit in that last bullet. They
* left it deliberately, and through a different gate: see `ERASURE_ROUTES`
* below. The widening is written down there, which is what this file is for.
*/
const ELEVATION_ROUTES = [
"app/api/auth/passkeys/[id]/route.ts",
"app/api/auth/me/mfa/disable/route.ts",
"app/api/auth/me/mfa/recovery-codes/regenerate/route.ts",
"app/api/auth/me/mfa/totp/confirm/route.ts",
"app/api/auth/me/mfa/totp/setup/route.ts",
"app/api/auth/me/mfa/webauthn/[id]/route.ts",
"app/api/auth/me/mfa/webauthn/register/options/route.ts",
"app/api/auth/me/mfa/webauthn/register/verify/route.ts",
].sort();
/**
* The two erasure routes that accept a FRESH-FACTOR elevation over Bearer.
*
* App Store guideline 5.1.1(v) requires that an account created in the app can
* be deleted from the app. Both routes gated an MFA-enrolled account on a
* cookie-side fresh factor and a Bearer transport had no way to present one,
* so a native-app user with a second factor could not delete their account at
* all. They now resolve through `requireFreshMfaOrElevationIfEnrolled`, the
* sibling of `requireFreshMfaIfEnrolled` whose cookie arm is unchanged and
* whose Bearer arm runs the same elevation validation the MFA-management
* routes use, with `freshFactor` pinned on — so a password-proved elevation
* cannot erase an account, exactly as a password login cannot on the web.
*
* The set is two routes and must stay two. Password change, the encrypted
* export, key rotation and the sharing invite keep the cookie-only sibling;
* moving one of them here is a visible edit to this list, not a drive-by.
*/
const ERASURE_ROUTES = [
"app/api/settings/account/route.ts",
"app/api/settings/data/route.ts",
].sort();
describe("S1 — the elevation-accepting route set is frozen", () => {
// A sweep that finds nothing agrees with every allowlist, so the size of
// the tree being read is asserted before anything is concluded from it.
// Pinned below the real source-file count with headroom, not at one.
it("reads the tree it claims to sweep", () => {
expect(sourceFiles().length).toBeGreaterThan(1500);
});
it("only the known MFA-management mutations call the gate", () => {
const found = callers("requireMfaManagementAuth", API_HANDLER).filter(
(rel) => rel !== "lib/api-handler.ts",
);
expect(found).toEqual(ELEVATION_ROUTES);
});
it("follows an aliased import rather than losing it", () => {
// Guards the guard. Without this the allowlist above is one rename away
// from meaningless, which is precisely how the previous revision was
// evadable.
const probe = `
import { requireMfaManagementAuth as gate } from "@/lib/api-handler";
const auth = await gate();
`;
const names: string[] = [];
for (const m of probe.matchAll(
/import\s*\{([\s\S]*?)\}\s*from\s*["']([^"']+)["']/g,
)) {
for (const part of m[1].split(",")) {
const aliased = part
.trim()
.match(/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/);
if (aliased && aliased[1] === "requireMfaManagementAuth") {
names.push(aliased[2]);
}
}
}
expect(names).toEqual(["gate"]);
expect(new RegExp(`\\b${names[0]}\\s*\\(`).test(probe)).toBe(true);
});
it("no admin route calls the gate", () => {
const admin = callers("requireMfaManagementAuth", API_HANDLER).filter(
(rel) => rel.startsWith("app/api/admin/"),
);
expect(admin).toEqual([]);
});
it("the old cookie-only gates no longer linger on the MFA surface", () => {
for (const rel of ELEVATION_ROUTES) {
expect(importedAs(rel, "requireCookieAuth", API_HANDLER)).toEqual([]);
expect(importedAs(rel, "requireFreshMfa", API_HANDLER)).toEqual([]);
}
});
it("every gated route spends its elevation", () => {
// `commitElevation` is what makes the proof single-use. A route that gates
// but never commits would leave the elevation redeemable for the rest of
// its window — the one fail-open the two-phase split introduces, closed
// here.
for (const rel of ELEVATION_ROUTES) {
expect(read(rel)).toMatch(/\.commitElevation\(\)/);
}
});
});
describe("S6 — the erasure routes accept a fresh-factor elevation", () => {
it("only the two erasure routes call the erasure gate", () => {
const found = callers(
"requireFreshMfaOrElevationIfEnrolled",
API_HANDLER,
).filter((rel) => rel !== "lib/api-handler.ts");
expect(found).toEqual(ERASURE_ROUTES);
});
it("neither erasure route still carries the cookie-only sibling", () => {
for (const rel of ERASURE_ROUTES) {
expect(importedAs(rel, "requireFreshMfaIfEnrolled", API_HANDLER)).toEqual(
[],
);
expect(importedAs(rel, "requireFreshMfa", API_HANDLER)).toEqual([]);
}
});
it("every erasure route spends its elevation", () => {
for (const rel of ERASURE_ROUTES) {
expect(read(rel)).toMatch(/\.commitElevation\(\)/);
}
});
it("the erasure gate pins the fresh-factor arm and never reads a body factor", () => {
// A password-proved elevation must not erase an account, and the gate
// must not grow a second, inline factor check beside the elevation: one
// mint surface, one redemption path.
const body = exportedBlock(
"requireFreshMfaOrElevationIfEnrolled",
"export async function requireFreshMfaOrElevationIfEnrolled(",
);
expect(body).toMatch(/freshFactor:\s*true/);
expect(body).not.toMatch(/verifyMfaFactor|verifyTotp|recoveryCode/);
});
});
describe("S2 — mint and redeem happen in exactly one place each", () => {
it("only the gate claims an elevation", () => {
const found = callers("claimStepUpElevation", STEP_UP_MODULE).filter(
(rel) => rel !== "lib/auth/step-up.ts",
);
expect(found).toEqual([GATE_MODULE]);
});
it("only the gate validates one", () => {
const found = callers("validateStepUpElevation", STEP_UP_MODULE).filter(
(rel) => rel !== "lib/auth/step-up.ts",
);
expect(found).toEqual([GATE_MODULE]);
});
it("only the mint route mints one", () => {
const found = callers("mintStepUpElevation", STEP_UP_MODULE).filter(
(rel) => rel !== "lib/auth/step-up.ts",
);
expect(found).toEqual(["app/api/auth/step-up/route.ts"]);
});
it("no file outside the gate reads the elevation header", () => {
// Both spellings — the literal string AND the exported constant — across
// every source file, not only `route.ts`. A helper could read it too.
const offenders = sourceFiles()
.filter((rel) => rel !== GATE_MODULE)
.filter((rel) => {
const src = read(rel);
if (/["']x-step-up["']/i.test(src)) return true;
return (
importedAs(rel, "STEP_UP_ELEVATION_HEADER", API_HANDLER).length > 0
);
});
expect(offenders).toEqual([]);
});
});
describe("S3 — requireAdmin stays cookie-only", () => {
it("resolves through getSession and nothing else", () => {
const body = exportedBlock(
"requireAdmin",
"export async function requireAdmin(",
);
expect(body).toContain("await getSession()");
expect(body).not.toMatch(/authenticateBearer|resolveBearerToken/);
expect(body).not.toMatch(
/claimStepUpElevation|validateStepUpElevation|STEP_UP_ELEVATION_HEADER|requireMfaManagementAuth/,
);
});
});
describe("S4 — the mint surface refuses a cookie", () => {
it("both step-up routes resolve via requireBearerAuth only", () => {
for (const rel of [
"app/api/auth/step-up/route.ts",
"app/api/auth/step-up/options/route.ts",
]) {
expect(importedAs(rel, "requireBearerAuth", API_HANDLER)).toEqual([
"requireBearerAuth",
]);
expect(importedAs(rel, "requireAuth", API_HANDLER)).toEqual([]);
expect(importedAs(rel, "requireCookieAuth", API_HANDLER)).toEqual([]);
}
});
it("requireBearerAuth never consults the session cookie", () => {
const body = exportedBlock(
"requireBearerAuth",
"export async function requireBearerAuth(",
);
expect(body).toContain("authenticateBearer(");
expect(body).not.toContain("getSession(");
});
});
describe("S5 — a password proof cannot satisfy a fresh-factor gate", () => {
it("the fresh-factor set excludes password", () => {
// The B1 invariant, pinned at the definition. The web stamps
// `mfaVerifiedAt` only for a completed second factor or a primary passkey
// login; the Bearer arm must not be looser. Read from the definition's
// own module, which imports nothing: reaching it through `step-up.ts`
// loaded the database client and the generated Prisma client inside this
// test body, and that import alone ran past the test timeout on a loaded
// machine.
expect([...FRESH_FACTOR_METHODS].sort()).toEqual([
"passkey",
"totp",
"webauthn",
]);
expect(isFreshFactorMethod("password")).toBe(false);
});
it("the destructive routes all request the fresh-factor arm", () => {
// Miss this on one route and a password-proved elevation tears down the
// second factor there.
for (const rel of [
"app/api/auth/me/mfa/disable/route.ts",
"app/api/auth/me/mfa/recovery-codes/regenerate/route.ts",
"app/api/auth/me/mfa/webauthn/[id]/route.ts",
]) {
expect(read(rel)).toMatch(/freshFactor:\s*true/);
}
});
it("the claim re-checks the factor rather than trusting the validation", () => {
const src = readSource(
"src/lib/auth/step-up.ts",
"export async function claimStepUpElevation(",
);
const claim = src.slice(
src.indexOf("export async function claimStepUpElevation("),
);
expect(claim).toMatch(/requireFreshFactor\s*&&\s*!isFreshFactorMethod/);
});
});