diff --git a/src/__tests__/acting-account-boundary-guard.test.ts b/src/__tests__/acting-account-boundary-guard.test.ts index ec8ebafb7..883073335 100644 --- a/src/__tests__/acting-account-boundary-guard.test.ts +++ b/src/__tests__/acting-account-boundary-guard.test.ts @@ -16,6 +16,11 @@ import { describe, it, expect } from "vitest"; import { join } from "node:path"; +import { + API_HANDLER, + authGuardModules, + guardSourceOf, +} from "./helpers/auth-guards"; import { readSource, readSourceText, @@ -80,8 +85,16 @@ describe("the acting-account carrier is read in one place", () => { // Reads it. The one statement of "what does this request say it is acting // as", for both transports and for every caller that has to ask. "lib/auth/acting-carrier.ts", - // The resolver. The only thing that acts on the value. - "lib/api-handler.ts", + // The resolver. The only thing that acts on the value: the guard module + // that reads the carrier off the auth context. + "lib/auth/guards/acting.ts", + // Declares the field on the auth context the resolvers pass around, and + // explains it in prose. Holds no logic that could read it. + "lib/auth/guards/context.ts", + // Names the column in prose only: the record resolver's note on why the + // fence and the carrier read one session row. It reads the carrier + // through `acting.ts`. + "lib/auth/guards/record.ts", // v1.36.0 — the only thing that WRITES it: the switch endpoint sets it // through here, ending a grant clears it through here. The routes // themselves call these two functions and never name the column, which is @@ -180,7 +193,7 @@ describe("the cookie-only helpers cannot see the acting account", () => { const COOKIE_ONLY = ["requireAdmin", "requireCookieAuth", "requireFreshMfa"]; it.each(COOKIE_ONLY)("%s resolves the actor and nothing else", (name) => { - const body = functionBody(readSource("src/lib/api-handler.ts", name), name); + const body = functionBody(guardSourceOf(name), name); // Non-zero proof, twice over: the slice found a function, and the slice is // the function it claims to be. A renamed helper must fail here rather @@ -194,7 +207,7 @@ describe("the cookie-only helpers cannot see the acting account", () => { }); it("substitutes a data-scope user in exactly two functions", () => { - const src = readSource("src/lib/api-handler.ts", "requireRecordAuth"); + const src = guardSourceOf("requireRecordAuth", ["requireGuardianAuth"]); const record = functionBody(src, "requireRecordAuth"); expect(record).toContain("readActingCarrier"); @@ -210,8 +223,22 @@ describe("the cookie-only helpers cannot see the acting account", () => { // `readActingCarrier` is what turns a request into "acting as somebody // else". Three callers: the bare path, which refuses on it, and the two // record paths, which act on it. A fourth would be a mode nobody declared. - const callers = (src.match(/await readActingCarrier\(/g) ?? []).length; + // Counted over the whole auth layer, the wrapper included, because the + // bare path lives in another guard module than the record paths. + const callers = ( + [API_HANDLER, ...authGuardModules().map(([rel]) => rel)] + .map((rel) => read(rel)) + .join("\n") + .match(/await readActingCarrier\(/g) ?? [] + ).length; expect(callers).toBe(3); + expect( + ( + functionBody(guardSourceOf("requireAuth"), "requireAuth").match( + /await readActingCarrier\(/g, + ) ?? [] + ).length, + ).toBe(1); }); }); diff --git a/src/__tests__/auth-guards-entry-guard.test.ts b/src/__tests__/auth-guards-entry-guard.test.ts new file mode 100644 index 000000000..5b3dc6483 --- /dev/null +++ b/src/__tests__/auth-guards-entry-guard.test.ts @@ -0,0 +1,80 @@ +/** + * The request guards have one public entry. + * + * `requireAuth`, the record modes, the cookie-only helpers and the step-up + * gates live in `src/lib/auth/guards/`, one module per family, and + * `src/lib/api-handler.ts` re-exports them beside `apiHandler`. Routes, jobs + * and libraries import them from there. The guard modules import each other. + * Nothing else reaches into the folder. + * + * One entry keeps the structural guards that freeze the auth surface honest: + * they match an import of `@/lib/api-handler` and follow the local name to its + * calls (`step-up-elevation-guard`, `recent-proof-surface-guard`, + * `sharing-surface-guard`). A route that imported `requireMfaManagementAuth` + * from its guard module directly would be invisible to all of them. + */ +import { dirname, join, relative, sep } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { AUTH_GUARDS_DIR, authGuardModules } from "./helpers/auth-guards"; +import { + productionSources, + readSrc, + testSources, +} from "./helpers/source-metrics"; + +const SRC = join(process.cwd(), "src"); +const GUARDS = "lib/auth/guards/"; + +/** The first-party module a specifier names, relative to `src/`, or null. */ +function target(specifier: string, from: string): string | null { + if (specifier.startsWith("@/")) return specifier.slice(2); + if (specifier.startsWith(".")) { + return relative(SRC, join(SRC, dirname(from), specifier)) + .split(sep) + .join("/"); + } + return null; +} + +/** + * Every module specifier in a file: static imports and re-exports, type-only + * ones included (a type import is still a reach into the folder), and + * dynamic `import("…")` / `vi.mock("…")` with a literal. + */ +const SPECIFIER = + /\bfrom\s*["']([^"']+)["']|\b(?:import|mock|doMock)\(\s*["']([^"']+)["']/g; + +/** Files that import, re-export or mock anything in the guard folder. */ +function importersOfGuards(files: readonly string[]): string[] { + return files.filter((rel) => { + const text = readSrc(rel); + if (!text.includes("guards")) return false; + return [...text.matchAll(SPECIFIER)].some((m) => + (target(m[1] ?? m[2], rel) ?? "").startsWith(GUARDS), + ); + }); +} + +describe("the auth guards' public entry", () => { + it("is the only module outside the folder that imports a guard module", () => { + const outside = importersOfGuards(productionSources()).filter( + (rel) => !rel.startsWith(GUARDS), + ); + // Non-zero proof: the entry itself must be found, or the matcher is blind. + expect(outside.length).toBeGreaterThan(0); + expect(outside).toEqual(["lib/api-handler.ts"]); + }); + + it("is not bypassed by tests either", () => { + // Tests import the guards the way routes do, so a mock of + // `@/lib/api-handler` stays the one seam they replace. + expect(importersOfGuards(testSources())).toEqual([]); + }); + + it("covers a folder that is still there", () => { + expect(authGuardModules().length).toBeGreaterThanOrEqual(10); + expect(AUTH_GUARDS_DIR).toBe(`src/${GUARDS.slice(0, -1)}`); + }); +}); diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index ae77487b9..f6b268ac4 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 121, - "total": 143770, + "count": 120, + "total": 141792, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -55,7 +55,6 @@ "lib/analytics/score/reader.ts": 998, "lib/analytics/sleep-night.ts": 1345, "lib/analytics/summaries-slice.ts": 1248, - "lib/api-handler.ts": 1978, "lib/auth/session.ts": 811, "lib/cache/server-cache.ts": 851, "lib/cycle/prediction.ts": 883, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 65ad892a5..61a2cd86c 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2954, - "total": 10499, + "total": 10481, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1887,7 +1887,7 @@ "lib/analytics/window-confidence.ts": 1, "lib/analytics/window-trend.ts": 1, "lib/api-errors.ts": 2, - "lib/api-handler.ts": 22, + "lib/api-handler.ts": 5, "lib/api-response.ts": 8, "lib/api/__tests__/api-fetch.test.ts": 1, "lib/api/__tests__/optimistic-token.test.ts": 1, diff --git a/src/__tests__/bearer-scope-enforcement-guard.test.ts b/src/__tests__/bearer-scope-enforcement-guard.test.ts index 8f38b2dc1..0d8b5315e 100644 --- a/src/__tests__/bearer-scope-enforcement-guard.test.ts +++ b/src/__tests__/bearer-scope-enforcement-guard.test.ts @@ -15,6 +15,7 @@ import { describe, it, expect } from "vitest"; import { join } from "node:path"; +import { guardSourceOf } from "./helpers/auth-guards"; import { readSource, readSourceText, @@ -226,8 +227,9 @@ describe("T1 — the Bearer resolution set is frozen", () => { const RESOLUTION_ALLOWLIST = [ // The resolver. Owns the one authorisation arm. "lib/auth/bearer.ts", - // The REST edge — maps `requireAuth`'s optional scope onto the union. - "lib/api-handler.ts", + // The REST edge — maps `requireAuth`'s optional scope onto the union. The + // guard module behind `requireAuth`, re-exported by `lib/api-handler.ts`. + "lib/auth/guards/caller.ts", // The MCP edge — the one deliberate `any-valid-token` posture. "lib/mcp/auth.ts", // The `/mcp` transport, via `resolveMcpAuthContext`. @@ -543,7 +545,7 @@ describe("T5 — a delegable route naming a Bearer scope is frozen", () => { // it and every other leg in this file stays green while a single-purpose // token gains delegated write — so the refusal is asserted where it lives, // not only through the behaviour that depends on it. - const src = readSource("src/lib/api-handler.ts", "narrow_scope_selector"); + const src = guardSourceOf("requireRecordAuth", ["narrow_scope_selector"]); expect(src).toMatch(/narrow_scope_selector/); expect(src).toMatch(/isScopedCredential\(auth\)/); }); diff --git a/src/__tests__/helpers/auth-guards.ts b/src/__tests__/helpers/auth-guards.ts new file mode 100644 index 000000000..5b2793708 --- /dev/null +++ b/src/__tests__/helpers/auth-guards.ts @@ -0,0 +1,83 @@ +/** + * Where the request guards live, for the structural tests that read them. + * + * `apiHandler` and the guards it fronts (`requireAuth`, the record modes, the + * cookie-only helpers, the step-up gates) were one file. The guards now live + * in `src/lib/auth/guards/`, one module per family, and `src/lib/api-handler.ts` + * keeps the wrapper and re-exports them as the one public entry. + * + * A test that pins a guard reads the module that DECLARES it, found by name + * here, rather than a hard-coded path. Two properties follow: moving a guard + * between the modules does not need every test edited, and a guard that is + * renamed or deleted throws here instead of leaving a negative assertion green + * over a file that no longer holds it. + */ +import { readSource, readSourceFolder, requireFloor } from "./source-files"; + +/** The guard folder, relative to the repository root. */ +export const AUTH_GUARDS_DIR = "src/lib/auth/guards"; + +/** The route wrapper and public entry, relative to `src/`. */ +export const API_HANDLER = "lib/api-handler.ts"; + +/** How many guard modules there are at least; a smaller folder is a broken read. */ +const GUARD_MODULE_FLOOR = 10; + +/** Every guard module as `[path relative to src/, text]`, sorted. */ +export function authGuardModules(): Array<[string, string]> { + const modules = readSourceFolder(AUTH_GUARDS_DIR, { + floor: GUARD_MODULE_FLOOR, + }).map(([rel, text]): [string, string] => [`lib/auth/guards/${rel}`, text]); + return requireFloor( + "auth guard modules", + modules, + GUARD_MODULE_FLOOR, + ) as Array<[string, string]>; +} + +/** Paths (relative to `src/`) of the guard modules plus the wrapper. */ +export function authLayerFiles(): string[] { + return [API_HANDLER, ...authGuardModules().map(([rel]) => rel)].sort(); +} + +function declares(text: string, name: string): boolean { + return new RegExp(`(^|\\n)(export )?(async )?function ${name}\\s*[(<]`).test( + text, + ); +} + +/** + * The guard module (relative to `src/`) that declares `function name(`. + * Throws unless exactly one does. + */ +export function guardModuleOf(name: string): string { + const found = authGuardModules() + .filter(([, text]) => declares(text, name)) + .map(([rel]) => rel); + if (found.length !== 1) { + throw new Error( + `guardModuleOf(${name}): expected exactly one module under ` + + `${AUTH_GUARDS_DIR} to declare it, found ${found.length} ` + + `(${found.join(", ") || "none"}).`, + ); + } + return found[0]; +} + +/** + * The text of the module that declares `name`, read with `readSource` so the + * name and every extra anchor must be present in it. + */ +export function guardSourceOf( + name: string, + extraAnchors: readonly (string | RegExp)[] = [], +): string { + return readSource(`src/${guardModuleOf(name)}`, [name, ...extraAnchors]); +} + +/** All guard modules joined, for counts that span the folder. */ +export function authGuardsText(): string { + return authGuardModules() + .map(([, text]) => text) + .join("\n"); +} diff --git a/src/__tests__/manage-activity-inventory.test.ts b/src/__tests__/manage-activity-inventory.test.ts index 83f051f91..0a69a99b3 100644 --- a/src/__tests__/manage-activity-inventory.test.ts +++ b/src/__tests__/manage-activity-inventory.test.ts @@ -71,8 +71,9 @@ const RECORD_RESOLVER = "requireRecordAuth"; /** * Rows the feed cannot show, and the reason each one cannot. * - * `src/lib/api-handler.ts` is a direct import of every route, so the walk - * below reaches the three actions the auth kit files itself. None of them can + * `src/lib/api-handler.ts` is a direct import of every route, and it + * re-exports the guards in `src/lib/auth/guards/`, so the walk below reaches + * the three actions the auth kit files itself. None of them can * appear in a record owner's activity view, and the reason is the same for all * three: they are filed under `auth.user.id` — the CALLER — at a point where * no record has been resolved. The feed selects rows where `userId` is the @@ -148,6 +149,13 @@ interface ModuleFacts { auditActions: string[]; /** First-party modules this one imports, resolved to files. */ imports: string[]; + /** + * First-party modules this one re-exports with `export … from`, resolved to + * files. A re-export makes the named module's bindings this module's own, + * so its actions count as this module's (`lib/api-handler.ts` re-exports + * the guards in `lib/auth/guards/`). + */ + reexports: string[]; } /** @@ -173,7 +181,19 @@ export function moduleFacts(source: string, fileName: string): ModuleFacts { const aliases = new Map(); const namespaces = new Set(); const importPaths: string[] = []; + const reexportPaths: string[] = []; parsed.forEachChild((node) => { + if ( + ts.isExportDeclaration(node) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) { + const resolved = resolveFirstParty(node.moduleSpecifier.text, fileName); + if (resolved !== null && !resolved.includes("__tests__")) { + reexportPaths.push(resolved); + } + return; + } if (!ts.isImportDeclaration(node)) return; if (ts.isStringLiteral(node.moduleSpecifier)) { const resolved = resolveFirstParty(node.moduleSpecifier.text, fileName); @@ -232,7 +252,13 @@ export function moduleFacts(source: string, fileName: string): ModuleFacts { }; parsed.forEachChild(visit); - return { delegatesRecord, recordLevels, auditActions, imports: importPaths }; + return { + delegatesRecord, + recordLevels, + auditActions, + imports: importPaths, + reexports: reexportPaths, + }; } /** @@ -267,7 +293,8 @@ export function resolveFirstParty( * to this route can file; one level is where a route's own collaborators live * — the import-admission module and the medication lifecycle module are both * direct imports of the routes that use them, and both were invisible to a - * walk that read only `route.ts`. + * walk that read only `route.ts`. A module an import re-exports is part of + * that import, not a level below it, so its actions count at the same level. */ function actionsReachableFrom( file: string, @@ -276,7 +303,41 @@ function actionsReachableFrom( const own = read(file); const out = [...own.auditActions]; for (const imported of own.imports) { - out.push(...read(imported).auditActions); + const facts = read(imported); + out.push(...facts.auditActions); + for (const reexported of facts.reexports) { + out.push(...packageActions(reexported, read)); + } + } + return out; +} + +/** + * The actions of a re-exported module together with the sibling modules it + * imports from its own folder. The re-exported module is a package's public + * face; the siblings are its private parts, which run inside the same call + * (`lib/auth/guards/record.ts` files its refusals through `./acting.ts`). + * Bounded to the one folder, so this does not become the transitive closure + * the walk above deliberately avoids. + */ +function packageActions( + file: string, + read: (path: string) => ModuleFacts, +): string[] { + const folder = dirname(file); + const seen = new Set([file]); + const queue = [file]; + const out: string[] = []; + while (queue.length > 0) { + const current = queue.shift() as string; + const facts = read(current); + out.push(...facts.auditActions); + for (const next of [...facts.imports, ...facts.reexports]) { + if (dirname(next) === folder && !seen.has(next)) { + seen.add(next); + queue.push(next); + } + } } return out; } diff --git a/src/__tests__/openapi-route-coverage-guard.test.ts b/src/__tests__/openapi-route-coverage-guard.test.ts index cf7bf490b..9c849be27 100644 --- a/src/__tests__/openapi-route-coverage-guard.test.ts +++ b/src/__tests__/openapi-route-coverage-guard.test.ts @@ -135,7 +135,7 @@ type HttpMethod = (typeof HTTP_METHODS)[number]; */ const REASONS = { /** - * `requireAdmin()` is cookie-only by construction (`src/lib/api-handler.ts`), + * `requireAdmin()` is cookie-only by construction (`src/lib/auth/guards/cookie.ts`), * so a Bearer token cannot reach these however wide its scope. The only * caller that can is the operator's own browser on the admin console, which * ships with the server and does not read a spec to find out what it talks diff --git a/src/__tests__/recent-proof-surface-guard.test.ts b/src/__tests__/recent-proof-surface-guard.test.ts index 4b0acd796..f3833b347 100644 --- a/src/__tests__/recent-proof-surface-guard.test.ts +++ b/src/__tests__/recent-proof-surface-guard.test.ts @@ -4,8 +4,8 @@ * Exporting the whole record, making a clinician share link, minting a token, * and the admin backup, restore, wipe and reset actions ask for a fresh proof, * not only a live session (`requireRecentProof` / `assertRecentCookieProof` in - * `src/lib/api-handler.ts`). This file pins which routes those are, so a route - * cannot drop the gate, and a new one cannot quietly join the list of routes + * `src/lib/auth/guards/recent-proof.ts`, imported through `src/lib/api-handler.ts`). + * This file pins which routes those are, so a route cannot drop the gate, and a new one cannot quietly join the list of routes * whose Bearer arm still takes the token alone. * * Matching is on the IMPORT and then on calls of the local name, the way @@ -16,11 +16,8 @@ import { describe, it, expect } from "vitest"; import { join } from "node:path"; -import { - readSource, - readSourceText, - walkSourceFiles, -} from "./helpers/source-files"; +import { guardSourceOf } from "./helpers/auth-guards"; +import { readSourceText, walkSourceFiles } from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); @@ -185,10 +182,9 @@ describe("the recent-proof gate", () => { }); it("the gate reads the session's own stamps, and only a second factor on an enrolled account", () => { - const src = readSource( - "src/lib/api-handler.ts", + const src = guardSourceOf("assertRecentCookieProof", [ "export async function assertRecentCookieProof(", - ); + ]); const start = src.indexOf("export async function assertRecentCookieProof("); expect(start).toBeGreaterThan(-1); const body = src.slice(start, src.indexOf("\nexport ", start + 1)); diff --git a/src/__tests__/record-session-fence-guard.test.ts b/src/__tests__/record-session-fence-guard.test.ts index eed7928bc..05677013e 100644 --- a/src/__tests__/record-session-fence-guard.test.ts +++ b/src/__tests__/record-session-fence-guard.test.ts @@ -41,6 +41,7 @@ import { describe, it, expect } from "vitest"; import { join } from "node:path"; +import { authGuardsText, guardSourceOf } from "./helpers/auth-guards"; import { readSource, readSourceText, @@ -84,10 +85,18 @@ function functionBody(src: string, name: string): string { return ""; } -const API_HANDLER = readSource("src/lib/api-handler.ts", [ - "requireRecordAuth", - "requireGuardianAuth", -]); +/** + * The auth layer: the route wrapper and every guard module it re-exports, + * joined, with the two record resolvers as anchors so a move that loses them + * throws instead of leaving the negative legs below green. + */ +const AUTH_LAYER = (() => { + guardSourceOf("requireRecordAuth", ["requireGuardianAuth"]); + return [ + readSource("src/lib/api-handler.ts", "apiHandler"), + authGuardsText(), + ].join("\n"); +})(); /** The two helpers that resolve a record and therefore must consult the fence. */ const FENCED_RESOLVERS = ["requireRecordAuth", "requireGuardianAuth"] as const; @@ -124,13 +133,18 @@ const UNFENCED_HELPERS = [ ] as const; describe("the fence runs in exactly two auth helpers", () => { - it("is called by both record resolvers and by nothing else in api-handler", () => { - const calls = [...API_HANDLER.matchAll(/assertRecordSessionFence\s*\(/g)]; + it("is called by both record resolvers and by nothing else in the auth layer", () => { + const calls = [ + ...authGuardsText().matchAll(/assertRecordSessionFence\s*\(/g), + ]; + expect(readSource("src/lib/api-handler.ts", "apiHandler")).not.toMatch( + /assertRecordSessionFence\s*\(/, + ); // Non-zero proof: an emptied file would agree with an emptied expectation. expect(calls.length).toBeGreaterThan(0); const callers = FENCED_RESOLVERS.filter((name) => - functionBody(API_HANDLER, name).includes("assertRecordSessionFence("), + functionBody(AUTH_LAYER, name).includes("assertRecordSessionFence("), ); expect(callers).toEqual([...FENCED_RESOLVERS]); // Two call sites, one import. Anything else means a third caller appeared. @@ -140,7 +154,7 @@ describe("the fence runs in exactly two auth helpers", () => { it.each(FENCED_RESOLVERS)( "%s fences after authentication and before the carrier and the grant", (name) => { - const body = functionBody(API_HANDLER, name); + const body = functionBody(AUTH_LAYER, name); expect(body.length).toBeGreaterThan(100); const fence = body.indexOf("assertRecordSessionFence("); @@ -163,11 +177,11 @@ describe("the fence runs in exactly two auth helpers", () => { it("fences before any grant lookup in the shared resolver path", () => { // `findActiveGrant` lives in `resolveSwitchedRecord`, which both resolvers // tail-call. Assert it appears nowhere ABOVE the fence in either body. - const grantCalls = [...API_HANDLER.matchAll(/findActiveGrant\s*\(/g)]; + const grantCalls = [...authGuardsText().matchAll(/findActiveGrant\s*\(/g)]; expect(grantCalls.length).toBeGreaterThan(0); for (const name of FENCED_RESOLVERS) { - const body = functionBody(API_HANDLER, name); + const body = functionBody(AUTH_LAYER, name); const fence = body.indexOf("assertRecordSessionFence("); const grant = body.indexOf("findActiveGrant("); // Either the body never names it (it is in the shared tail) or it names @@ -177,7 +191,7 @@ describe("the fence runs in exactly two auth helpers", () => { }); it.each(UNFENCED_HELPERS)("%s deliberately does not fence", (name) => { - const body = functionBody(API_HANDLER, name); + const body = functionBody(AUTH_LAYER, name); expect(body.length).toBeGreaterThan(50); expect(body).not.toContain("assertRecordSessionFence"); }); @@ -226,7 +240,7 @@ describe("the epoch has one writer and a closed reader set", () => { // Projects it off the session row it already loaded. "lib/auth/session.ts", // Carries it on the auth context the fence is handed. - "lib/api-handler.ts", + "lib/auth/guards/context.ts", // Compares it. The one place that decides anything from it. "lib/sharing/record-session-fence.ts", // Reads it off the same session lookup the idempotency wrapper already diff --git a/src/__tests__/session-user-projection-guard.test.ts b/src/__tests__/session-user-projection-guard.test.ts index ad62e0f7d..c13344570 100644 --- a/src/__tests__/session-user-projection-guard.test.ts +++ b/src/__tests__/session-user-projection-guard.test.ts @@ -3,7 +3,7 @@ * * Every authenticated request resolves its caller through the session lookup * (`lib/auth/session.ts`), the Bearer resolver (`lib/auth/bearer.ts`) or, for a - * switched record, the owner load in `lib/api-handler.ts`. They used to + * switched record, the owner load in `lib/auth/guards/record.ts`. They used to * `include: { user: true }` / `findUnique({ where })`, which copied the profile * picture (megabytes), the cached briefing text and every stored credential * into memory on each request. They now name `SESSION_USER_SELECT`. @@ -19,6 +19,7 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { guardModuleOf } from "./helpers/auth-guards"; import { walkSourceFiles } from "./helpers/source-files"; import { SESSION_USER_SELECT } from "@/lib/auth/session-user"; @@ -70,7 +71,10 @@ describe("caller lookups load the session projection", () => { }); it("the Bearer resolver and the switched-owner load select SESSION_USER_SELECT", () => { - for (const rel of ["lib/auth/bearer.ts", "lib/api-handler.ts"]) { + for (const rel of [ + "lib/auth/bearer.ts", + guardModuleOf("resolveSwitchedRecord"), + ]) { const calls = callObjects(read(rel), USER_LOOKUP); expect(calls.length, rel).toBeGreaterThan(0); for (const body of calls) { diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index caf7b3a4e..863e0f139 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -75,6 +75,7 @@ import { ENTIRE_RECORD, SHARE_DOMAINS } from "@/lib/sharing/scope"; import type { ShareScope } from "@/lib/sharing/scope"; import { ADMITTED_MUTATING_HANDLERS } from "../../tests/fixtures/v137/sharing-matrix"; +import { API_HANDLER, guardModuleOf } from "./helpers/auth-guards"; import { readSourceText } from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); @@ -115,7 +116,7 @@ const VALID_SCOPES: ReadonlySet = new Set([ * "is this caller an admin", "has this caller just re-proven a second factor", * "is this caller here over a cookie". All three resolve through `getSession()` * and see the actor, never the owner — that is what - * `acting-account-boundary-guard.test.ts` pins inside `api-handler.ts`. + * `acting-account-boundary-guard.test.ts` pins inside `lib/auth/guards/`. * * This file pins the other end. A handler that resolves a RECORD and then runs * one of these has put a role check and a substituted data scope in the same @@ -721,6 +722,25 @@ function functionSource(rel: string, name: string): string { return found; } +/** Names a module re-exports with `export { … } from "…"`. */ +function reexportedNames(rel: string): string[] { + const parsed = ts.createSourceFile( + rel, + read(rel), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const names: string[] = []; + parsed.forEachChild((node) => { + if (!ts.isExportDeclaration(node) || !node.moduleSpecifier) return; + const clause = node.exportClause; + if (!clause || !ts.isNamedExports(clause)) return; + for (const spec of clause.elements) names.push(spec.name.text); + }); + return names; +} + /** Names of the top-level functions a module exports. */ function exportedFunctionNames(rel: string): string[] { const parsed = ts.createSourceFile( @@ -2571,11 +2591,17 @@ describe("the resolvers exist and live in one place", () => { // between "no route administers a managed profile" and "the guard is // looking for a function that does not exist": its frozen list is empty, // so its leg's negative result proves nothing on its own. - const exported = exportedFunctionNames("lib/api-handler.ts"); + const declaring = guardModuleOf(RECORD_RESOLVER); + const exported = exportedFunctionNames(declaring); expect(exported.length).toBeGreaterThan(0); expect(exported).toContain(RECORD_RESOLVER); expect(exported).toContain(ACTOR_RESOLVER); expect(exported).toContain(GUARDIAN_RESOLVER); + // And the public entry routes import them from re-exports all three. + const reexported = reexportedNames(API_HANDLER); + expect(reexported).toContain(RECORD_RESOLVER); + expect(reexported).toContain(ACTOR_RESOLVER); + expect(reexported).toContain(GUARDIAN_RESOLVER); }); it("nothing outside the frozen route lists reaches any resolver", () => { @@ -2596,7 +2622,9 @@ describe("the resolvers exist and live in one place", () => { expect(naming).toEqual( [ // Declares all three. Not a caller. - "lib/api-handler.ts", + guardModuleOf(RECORD_RESOLVER), + // Re-exports all three as the public entry. Not a caller. + API_HANDLER, ...Object.keys(DELEGABLE_ROUTES), ...Object.keys(GUARDIAN_ROUTES), ...Object.keys(ACTOR_ROUTES), @@ -2631,10 +2659,11 @@ describe("(a) the delegable route set is frozen", () => { // names nothing" were the same sentence; the list is populated now, so the // assertion below would catch a neutered matcher on its own. Kept anyway, // because a future removal could empty the list again and the day that - // happens is not the day to rediscover this. `lib/api-handler.ts` is the - // guaranteed positive — it declares the symbol. - expect(modulesNaming(["lib/api-handler.ts"], RECORD_RESOLVER)).toEqual([ - "lib/api-handler.ts", + // happens is not the day to rediscover this. The guard module that + // declares the symbol is the guaranteed positive. + const recordModule = guardModuleOf(RECORD_RESOLVER); + expect(modulesNaming([recordModule], RECORD_RESOLVER)).toEqual([ + recordModule, ]); expect(modulesNaming(scanned, RECORD_RESOLVER)).toEqual( @@ -2654,9 +2683,8 @@ describe("(b) the actor-surface route set is frozen", () => { expect(scanned.length).toBeGreaterThan(ROUTE_MODULE_FLOOR); // Positive control, same reason as leg (a). - expect(modulesNaming(["lib/api-handler.ts"], ACTOR_RESOLVER)).toEqual([ - "lib/api-handler.ts", - ]); + const actorModule = guardModuleOf(ACTOR_RESOLVER); + expect(modulesNaming([actorModule], ACTOR_RESOLVER)).toEqual([actorModule]); expect(modulesNaming(scanned, ACTOR_RESOLVER)).toEqual( Object.keys(ACTOR_ROUTES).sort(), @@ -2747,14 +2775,22 @@ describe("(c) a record resolver never shares a module with a role or step-up che }); it("the declaration site is out of scope by construction", () => { - // `lib/api-handler.ts` declares the record resolver AND all three cookie- - // only helpers, so a scan of every source file would need it exempted, and - // an exemption is a hole. Scoping the leg to handler modules removes the - // need for one. This asserts the scoping still holds. - const src = exportedFunctionNames("lib/api-handler.ts"); - expect(src).toContain(RECORD_RESOLVER); - for (const helper of COOKIE_ONLY_HELPERS) expect(src).toContain(helper); - expect(routeModules()).not.toContain("lib/api-handler.ts"); + // The auth layer declares the record resolver AND all three cookie-only + // helpers (in `lib/auth/guards/`, re-exported by `lib/api-handler.ts`), so + // a scan of every source file would need those modules exempted, and an + // exemption is a hole. Scoping the leg to handler modules removes the need + // for one. This asserts the scoping still holds. + const declaring = [RECORD_RESOLVER, ...COOKIE_ONLY_HELPERS].map((name) => + guardModuleOf(name), + ); + expect(exportedFunctionNames(declaring[0])).toContain(RECORD_RESOLVER); + COOKIE_ONLY_HELPERS.forEach((helper, i) => + expect(exportedFunctionNames(declaring[i + 1])).toContain(helper), + ); + const routes = routeModules(); + for (const rel of [API_HANDLER, ...declaring]) { + expect(routes).not.toContain(rel); + } }); }); @@ -3166,7 +3202,7 @@ describe("(g) the MANAGE route set is frozen", () => { // …and the fact it reads has to be stamped. The resolver is where the // owner row is in hand, so it is the only place that can decide it. const resolver = functionSource( - "lib/api-handler.ts", + guardModuleOf("resolveSwitchedRecord"), "resolveSwitchedRecord", ); expect(resolver.length).toBeGreaterThan(0); @@ -3357,10 +3393,11 @@ describe("(h) the guardian route set is frozen", () => { // Positive control for this leg's own matcher, the same one leg (a) uses // and for the same reason twice over: the literal is empty, so "no route // names the resolver" and "the matcher names nothing" are one sentence - // until something the matcher CAN find is named. `lib/api-handler.ts` - // declares the symbol and is the guaranteed positive. - expect(modulesNaming(["lib/api-handler.ts"], GUARDIAN_RESOLVER)).toEqual([ - "lib/api-handler.ts", + // until something the matcher CAN find is named. The guard module that + // declares the symbol is the guaranteed positive. + const guardianModule = guardModuleOf(GUARDIAN_RESOLVER); + expect(modulesNaming([guardianModule], GUARDIAN_RESOLVER)).toEqual([ + guardianModule, ]); expect(modulesNaming(scanned, GUARDIAN_RESOLVER)).toEqual( @@ -3375,7 +3412,10 @@ describe("(h) the guardian route set is frozen", () => { // marker. A resolver that stopped reading it would leave every leg here // passing and the identity fence gone — an invited adult's MANAGE grant // would reach the settings of a record its owner still runs. - const guardian = functionSource("lib/api-handler.ts", GUARDIAN_RESOLVER); + const guardian = functionSource( + guardModuleOf(GUARDIAN_RESOLVER), + GUARDIAN_RESOLVER, + ); // Non-zero proof, twice: the slice found a function, and it is the one it // claims to be. A renamed resolver must fail here rather than assert // against an empty string. @@ -3386,7 +3426,10 @@ describe("(h) the guardian route set is frozen", () => { // …and the record resolver must NOT read it. The two resolvers differ in // exactly this fact, so a marker check that drifted into the delegable // path would make the fence meaningless while every list still agreed. - const record = functionSource("lib/api-handler.ts", RECORD_RESOLVER); + const record = functionSource( + guardModuleOf(RECORD_RESOLVER), + RECORD_RESOLVER, + ); expect(record.length).toBeGreaterThan(0); expect(record).not.toContain("managedProfileAt"); }); diff --git a/src/__tests__/step-up-elevation-guard.test.ts b/src/__tests__/step-up-elevation-guard.test.ts index 250f9bb83..1c374e284 100644 --- a/src/__tests__/step-up-elevation-guard.test.ts +++ b/src/__tests__/step-up-elevation-guard.test.ts @@ -25,6 +25,7 @@ import { describe, it, expect } from "vitest"; import { join } from "node:path"; +import { guardModuleOf, guardSourceOf } from "./helpers/auth-guards"; import { readSource, readSourceText, @@ -77,6 +78,28 @@ function importedAs( 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() @@ -260,15 +283,10 @@ describe("S6 — the erasure routes accept a fresh-factor elevation", () => { // 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 src = readSource( - "src/lib/api-handler.ts", + const body = exportedBlock( + "requireFreshMfaOrElevationIfEnrolled", "export async function requireFreshMfaOrElevationIfEnrolled(", ); - const start = src.indexOf( - "export async function requireFreshMfaOrElevationIfEnrolled(", - ); - expect(start).toBeGreaterThan(-1); - const body = src.slice(start, src.indexOf("\nexport ", start + 1)); expect(body).toMatch(/freshFactor:\s*true/); expect(body).not.toMatch(/verifyMfaFactor|verifyTotp|recoveryCode/); }); @@ -279,14 +297,14 @@ describe("S2 — mint and redeem happen in exactly one place each", () => { const found = callers("claimStepUpElevation", STEP_UP_MODULE).filter( (rel) => rel !== "lib/auth/step-up.ts", ); - expect(found).toEqual(["lib/api-handler.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(["lib/api-handler.ts"]); + expect(found).toEqual([GATE_MODULE]); }); it("only the mint route mints one", () => { @@ -300,7 +318,7 @@ describe("S2 — mint and redeem happen in exactly one place each", () => { // 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 !== "lib/api-handler.ts") + .filter((rel) => rel !== GATE_MODULE) .filter((rel) => { const src = read(rel); if (/["']x-step-up["']/i.test(src)) return true; @@ -314,13 +332,9 @@ describe("S2 — mint and redeem happen in exactly one place each", () => { describe("S3 — requireAdmin stays cookie-only", () => { it("resolves through getSession and nothing else", () => { - const src = readSource("src/lib/api-handler.ts", [ + const body = exportedBlock( + "requireAdmin", "export async function requireAdmin(", - "export async function requireCookieAuth(", - ]); - const body = src.slice( - src.indexOf("export async function requireAdmin("), - src.indexOf("export async function requireCookieAuth("), ); expect(body).toContain("await getSession()"); expect(body).not.toMatch(/authenticateBearer|resolveBearerToken/); @@ -345,14 +359,11 @@ describe("S4 — the mint surface refuses a cookie", () => { }); it("requireBearerAuth never consults the session cookie", () => { - const src = readSource("src/lib/api-handler.ts", [ + const body = exportedBlock( + "requireBearerAuth", "export async function requireBearerAuth(", - "export const STEP_UP_ELEVATION_HEADER", - ]); - const body = src.slice( - src.indexOf("export async function requireBearerAuth("), - src.indexOf("export const STEP_UP_ELEVATION_HEADER"), ); + expect(body).toContain("authenticateBearer("); expect(body).not.toContain("getSession("); }); }); diff --git a/src/__tests__/write-rate-limit-guard.test.ts b/src/__tests__/write-rate-limit-guard.test.ts index 8498633f5..5b6c7a2da 100644 --- a/src/__tests__/write-rate-limit-guard.test.ts +++ b/src/__tests__/write-rate-limit-guard.test.ts @@ -26,11 +26,15 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { guardSourceOf } from "./helpers/auth-guards"; import { walkSourceFiles } from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); -/** The resolvers in `api-handler.ts` that charge the shared write bucket. */ +/** + * The resolvers that charge the shared write bucket: the guards in + * `lib/auth/guards/`, imported by routes through `lib/api-handler.ts`. + */ const METERING_RESOLVERS = [ "requireAuth", "requireRecordAuth", @@ -194,7 +198,16 @@ describe("every mutating route meets a rate limit", () => { }); it("charges the bucket in each metering resolver", () => { - const handler = read("lib/api-handler.ts"); + /** The resolver's source, from its signature to the next export. */ + const resolverBody = (name: string): string => { + const signature = new RegExp( + `export\\s+async\\s+function\\s+${name}\\s*\\(`, + ); + const handler = guardSourceOf(name, [signature]); + const start = handler.search(signature); + const next = handler.indexOf("\nexport ", start + 1); + return handler.slice(start, next === -1 ? undefined : next); + }; for (const name of [ "requireAuth", "requireActorAuth", @@ -205,13 +218,7 @@ describe("every mutating route meets a rate limit", () => { "requireFreshMfa", "requireBearerAuth", ]) { - const start = handler.search( - new RegExp(`export\\s+async\\s+function\\s+${name}\\s*\\(`), - ); - expect(start, name).toBeGreaterThanOrEqual(0); - const next = handler.indexOf("\nexport ", start + 1); - const body = handler.slice(start, next === -1 ? undefined : next); - expect(body, name).toMatch(/\bmeterAccountWrite\s*\(/); + expect(resolverBody(name), name).toMatch(/\bmeterAccountWrite\s*\(/); } // The three that only delegate reach the meter through `requireAuth`. for (const name of [ @@ -219,11 +226,7 @@ describe("every mutating route meets a rate limit", () => { "requireFreshMfaOrElevationIfEnrolled", "requireRecentProof", ]) { - const start = handler.search( - new RegExp(`export\\s+async\\s+function\\s+${name}\\s*\\(`), - ); - const next = handler.indexOf("\nexport ", start + 1); - expect(handler.slice(start, next), name).toMatch(/\brequireAuth\s*\(/); + expect(resolverBody(name), name).toMatch(/\brequireAuth\s*\(/); } }); }); diff --git a/src/app/api/__tests__/read-routes-write-nothing.test.ts b/src/app/api/__tests__/read-routes-write-nothing.test.ts index 4418293a8..c5ce72c89 100644 --- a/src/app/api/__tests__/read-routes-write-nothing.test.ts +++ b/src/app/api/__tests__/read-routes-write-nothing.test.ts @@ -1,7 +1,7 @@ /** * Structural guard: the three read routes that used to write must stay reads. * - * `src/lib/api-handler.ts` states the invariant next to `READ_HTTP_METHODS` — + * `src/lib/auth/guards/context.ts` states the invariant next to `READ_HTTP_METHODS` — * an MCP-audience token is admitted on GET/HEAD/OPTIONS on the REST surface * because those methods are assumed side-effect-free, and adding a * side-effecting GET silently widens that token's reach. These three routes diff --git a/src/app/api/auth/native/__tests__/invariants.test.ts b/src/app/api/auth/native/__tests__/invariants.test.ts index b7dc50415..6ea62aab0 100644 --- a/src/app/api/auth/native/__tests__/invariants.test.ts +++ b/src/app/api/auth/native/__tests__/invariants.test.ts @@ -11,6 +11,8 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { guardSourceOf } from "@/__tests__/helpers/auth-guards"; + const SRC = join(process.cwd(), "src"); const read = (rel: string) => readFileSync(join(SRC, rel), "utf8"); @@ -94,18 +96,15 @@ describe("T11/T13 — cookie-only boundaries and no bundle-in-browser", () => { }); it("requireAdmin and requireFreshMfa remain cookie-only (getSession)", () => { - const handler = read("lib/api-handler.ts"); // Both resolve via getSession() (cookie-only) — restated as a tripwire so a - // future edit that threads a Bearer into either trips this test. - const requireAdmin = handler.slice( - handler.indexOf("export async function requireAdmin"), - handler.indexOf("export async function requireAdmin") + 400, - ); - expect(requireAdmin).toMatch(/getSession\(\)/); - const requireFresh = handler.slice( - handler.indexOf("export async function requireFreshMfa"), - handler.indexOf("export async function requireFreshMfa") + 400, - ); - expect(requireFresh).toMatch(/getSession\(\)/); + // future edit that threads a Bearer into either trips this test. Read from + // the guard module that declares each, which throws if it is gone. + const opening = (name: string) => { + const src = guardSourceOf(name, [`export async function ${name}(`]); + const start = src.indexOf(`export async function ${name}(`); + return src.slice(start, start + 600); + }; + expect(opening("requireAdmin")).toMatch(/getSession\(\)/); + expect(opening("requireFreshMfa")).toMatch(/getSession\(\)/); }); }); diff --git a/src/app/api/gamification/achievements/__tests__/writes-nothing.test.ts b/src/app/api/gamification/achievements/__tests__/writes-nothing.test.ts index e600c2bd0..fc04778b3 100644 --- a/src/app/api/gamification/achievements/__tests__/writes-nothing.test.ts +++ b/src/app/api/gamification/achievements/__tests__/writes-nothing.test.ts @@ -2,7 +2,7 @@ * `GET /api/gamification/achievements` is a read. * * It used to INSERT a `UserAchievement` row for every unlock it noticed, - * which is the side-effecting GET the MCP-audience note in `api-handler.ts` + * which is the side-effecting GET the MCP-audience note in `lib/auth/guards/context.ts` * warns against. The evaluation still runs here; only the persistence moved * to the `achievement-unlock-sweep` job. * diff --git a/src/app/api/gamification/achievements/route.ts b/src/app/api/gamification/achievements/route.ts index ac9dfe01c..799677d9a 100644 --- a/src/app/api/gamification/achievements/route.ts +++ b/src/app/api/gamification/achievements/route.ts @@ -97,7 +97,7 @@ export const GET = apiHandler(async (request: NextRequest) => { ); // v1.35.3 — the unlock rows are NOT written here. A GET must not have side - // effects (`api-handler.ts`, the MCP-audience note above + // effects (`lib/auth/guards/context.ts`, the MCP-audience note above // `READ_HTTP_METHODS`), and this INSERT was one. The badge grid does not // need the rows: `unlocked` comes from the live metrics and the completion // date is derived from the account's own history, so the payload below is diff --git a/src/app/api/sync/state/__tests__/route.test.ts b/src/app/api/sync/state/__tests__/route.test.ts index 46c8c3036..560728771 100644 --- a/src/app/api/sync/state/__tests__/route.test.ts +++ b/src/app/api/sync/state/__tests__/route.test.ts @@ -2,7 +2,7 @@ * `GET /api/sync/state` is a read. * * It used to bump `User.lastSyncedAt` on every call, which is the exact - * side-effecting GET the MCP-audience note in `api-handler.ts` warns against, + * side-effecting GET the MCP-audience note in `lib/auth/guards/context.ts` warns against, * and it advanced the checkpoint past a window the client had not drained. * The checkpoint is now owned by the ingest boundaries; this route reports it. */ diff --git a/src/app/mcp/route.ts b/src/app/mcp/route.ts index 126d9238d..8d925c25e 100644 --- a/src/app/mcp/route.ts +++ b/src/app/mcp/route.ts @@ -35,7 +35,7 @@ * (`log_measurement` / `log_mood`) ONLY for a `health:write`-scoped session; * a read-only token never sees them. Writes happen in-process here and are * audience-bound to `/mcp` — a `health:write` MCP token is refused on every - * REST write/delete by the resource-server guard in `api-handler.ts`, so it + * REST write/delete by the resource-server guard in `lib/auth/guards/caller.ts`, so it * can never become a general REST write credential. `requireAdmin()` stays * cookie-only, so admin is unreachable over this wire regardless of token * scope — including `["*"]` (ADR-003 / ADR-005). diff --git a/src/lib/README.md b/src/lib/README.md index d475e2001..fcffe12e9 100644 --- a/src/lib/README.md +++ b/src/lib/README.md @@ -4,7 +4,7 @@ The server and runtime toolkit. Everything that is not a page, component, or rou ## Core request kit -- **`api-handler.ts`** — the `apiHandler` wrapper every route uses. Auth resolver (cookie OR Bearer), `requireAuth` / `requireAdmin` (admin is cookie-only by construction), idempotency plumbing, Wide-Event builder, GlitchTip forwarder. +- **`api-handler.ts`** — the `apiHandler` wrapper every route uses (Wide-Event builder, error envelope, GlitchTip forwarder) and the one public entry for the request guards in `auth/guards/`: the auth resolver (cookie OR Bearer), `requireAuth` / `requireRecordAuth` / `requireAdmin` (admin is cookie-only by construction), the step-up gates. - **`api-response.ts`** — the `{ data, error, meta? }` envelope: `apiSuccess` / `apiError`, `safeJson`, trusted-proxy IP resolver, `returnAllZodIssues` + `sanitiseZodIssues`. - **`rate-limit.ts`** — Postgres-backed sliding-window limiter (single atomic upsert); multi-instance correct. - **`idempotency.ts`** — user-scoped idempotency keys that refuse to cache secret-shaped responses. diff --git a/src/lib/__tests__/require-auth-bearer.test.ts b/src/lib/__tests__/require-auth-bearer.test.ts index 0611a6854..af8cd90f9 100644 --- a/src/lib/__tests__/require-auth-bearer.test.ts +++ b/src/lib/__tests__/require-auth-bearer.test.ts @@ -312,7 +312,7 @@ describe("requireAuth — MCP-audience tokens have no REST reach", () => { // method — reads included. Its audience narrows from "/mcp + REST reads" to // "/mcp only", which is what RFC 8707 audience binding asked for. // - // The `isMcpAudienceToken` guard is retained in `api-handler.ts` as defence + // The `isMcpAudienceToken` guard is retained in `lib/auth/guards/caller.ts` as defence // in depth; it is unreachable on this path, which is exactly why these tests // assert `undeclared_scope` rather than `mcp_audience_write_blocked`. const route = apiHandler(async (req) => { diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 35f907691..060a86062 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -1,62 +1,27 @@ +/** + * The route wrapper: the wide event, the error envelope, the record-context + * echo and the rate-limit headers around every API handler. + * + * It is also the one public entry for the request guards. They live under + * `./auth/guards/`, one module per family (the caller, the record modes, the + * cookie-only helpers, the step-up gates), and are re-exported below so a + * route imports `apiHandler` and its guard from the same place. + */ import { NextRequest, NextResponse } from "next/server"; -import { headers } from "next/headers"; -import type { AccountGrant } from "@/generated/prisma/client"; -import { SESSION_USER_SELECT, type SessionUser } from "./auth/session-user"; import { applyBypassRouteHeaders, bypassRouteRefusal, isProxyBypassRoute, } from "@/lib/http/proxy-bypass-routes"; -import { prisma } from "@/lib/db"; import { WideEventBuilder } from "./logging/event-builder"; -import { annotate, eventStorage, getEvent } from "./logging/context"; +import { eventStorage } from "./logging/context"; import { emitIfSampled } from "./logging/transports"; import { redactOptional, redactSecrets } from "./logging/redact"; import { routeTemplateFor } from "./logging/route-template"; -import { getSession } from "./auth/session"; -import { - hasSecondFactorEnrolled, - recentProofMethods, - type RecentProofMethod, -} from "./auth/second-factor"; - -export { recentProofMethods, type RecentProofMethod }; -import { auditLog, recordDelegatedAccess } from "./auth/audit"; -import { - resolveBearerToken, - BearerAuthError, - type ScopeRequirement, -} from "./auth/bearer"; -import { - claimStepUpElevation, - validateStepUpElevation, - STEP_UP_ELEVATION_TTL_SECONDS, -} from "./auth/step-up"; -import { hashToken } from "./auth/hmac"; -import { - carrierTarget, - decideActingCarrier, - readSelectorHeader, - selectorNamesAnAccount, - type ActingCarrier, -} from "./auth/acting-carrier"; import { AiUnavailableError } from "./ai/capabilities/refusal"; import { ConsentRequiredError } from "./ai/consent-guard"; -import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "./mcp/oauth/config"; -import { - findActiveGrant, - grantAllows, - grantCoversDomain, - touchGrantUsage, - type GrantNeed, -} from "./sharing/grants"; -import type { ShareScope } from "./sharing/scope"; -import { - assertRecordSessionFence, - attachRecordContextEcho, -} from "./sharing/record-session-fence"; +import { attachRecordContextEcho } from "./sharing/record-session-fence"; import { refuseOnKeyMismatch } from "./boot/key-mismatch-refusal"; -import { meterAccountWrite, type WriteMeterOptions } from "./auth/write-meter"; import { acceptIncomingRequestId } from "./http/request-id"; import { withErrorEnvelopeDefaults } from "./api-response"; import { @@ -65,35 +30,10 @@ import { runWithRateLimitCapture, } from "./rate-limit-context"; -/** - * HTTP methods a read-only credential may use on the REST surface. A request - * with any other method (POST / PUT / PATCH / DELETE) is a write. - * - * The MCP-audience guard below assumes these methods are side-effect-free: - * an MCP-bound token is admitted on them. A future side-effecting GET (or HEAD) - * would silently widen that token's reach over REST — do NOT add one without - * revisiting the MCP audience binding here. - */ -const READ_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); - -/** - * Whether a token is MCP-audience-bound (H1). The MCP OAuth bridge and the - * connector settings card mint tokens whose ONLY grants are `health:read` — - * and, when the user consents to logging, `health:read health:write`. Either - * shape is bound to the MCP surface: the `/mcp` resolver accepts it and so do - * safe (read) REST methods, but it must NEVER reach a REST write/delete. The - * `health:write` grant admits writes ONLY in-process over `/mcp` (the confirmed - * write tools), never over REST — so a write-scoped MCP token is exactly as - * audience-bound on this edge as a read-only one. A token carrying any broader - * or legacy grant (`*`, `medication:ingest`, …) is NOT MCP-audience-bound and - * keeps its existing reach. - */ -export function isMcpAudienceToken(permissions: readonly string[]): boolean { - if (permissions.length === 0) return false; - return permissions.every( - (p) => p === SCOPE_HEALTH_READ || p === SCOPE_HEALTH_WRITE, - ); -} +export { + recentProofMethods, + type RecentProofMethod, +} from "./auth/second-factor"; // The error classes live in the LEAF module `api-errors.ts` (moved to break // the api-handler ↔ record-session-fence import cycle) and are re-exported @@ -106,7 +46,6 @@ import { SharingNotPermittedError, RecordSessionChangedError, StepUpRequiredError, - REPROOF_REQUIRED_CODE, } from "./api-errors"; export { @@ -441,307 +380,48 @@ function attachRateLimitHeaders(response: NextResponse): void { } } -/** - * Authenticated request context. Returned by both session-cookie and Bearer-token - * authentication paths. The `session.id` is the session-record id for cookie auth - * and the `ApiToken` id for bearer auth — callers must not assume the id refers - * to a Session row. - */ -export type AuthContext = { - session: { - id: string; - expiresAt: Date; - /** - * v1.36.0 — the cookie transport's acting-account carrier, straight off - * the session row. Absent on the Bearer path, which has no session row and - * carries its selector in a per-request header instead. - * - * Optional so that the ~180 test files which hand `requireAuth` a - * hand-built session context keep compiling; an absent field means the - * same thing as a null one — no switch. Only the acting-account resolver - * below reads it. - */ - readonly actingAsUserId?: string | null; - /** - * v1.37.0 — how many times this session's record selector has moved. The - * record-session fence compares it against what the request asserted. - * - * Optional for the same reason as `actingAsUserId` above, and `undefined` - * reads as `0` — which is the fence's exemption, so a hand-built test - * context keeps meaning "a session that never switched". Read only by the - * fence. - */ - readonly recordEpoch?: number; - }; - user: SessionUser; - /** - * Transport the caller authenticated with, derived from the SAME branch that - * sets the wide event's `auth_method`: the cookie-session path resolves - * `"cookie"`, the Bearer-token path resolves `"bearer"`. Read-only and never - * client-asserted — it is the transport fact, not a request field. A route - * that must record write provenance (e.g. the medication-intake `source`) - * maps it server-side, the same posture as `userId` being narrowed from auth. - */ - readonly authMethod: "cookie" | "bearer"; - /** - * The named scope that admitted this Bearer, or null when the credential is - * cookie-equivalent — a session, or a `["*"]` token. A non-null value marks a - * NARROW, single-purpose credential: one minted for one job, by its own - * holder, from their own settings page. - * - * A single nullable string rather than the token's permission array, and that - * is the point: nothing downstream can re-derive an authorisation decision - * from it. The decision was made once, in `resolveBearerToken`; this only - * reports which arm ran, so a route can refuse a narrow credential something - * a cookie session may still do. - * - * Optional for the reason `actingAsUserId` and `recordEpoch` above are: the - * test files that hand-build this object keep compiling, and an absent field - * reads as null — "cookie-equivalent", which is what a hand-built context has - * always meant. - */ - readonly bearerScope?: string | null; -}; - -/** - * Was this request authenticated by a narrow, single-purpose Bearer scope? - * - * The one reader of `bearerScope`, exported so no handler re-derives the test - * and they cannot drift on what "narrow" means. - */ -export function isScopedCredential(auth: AuthContext): boolean { - return (auth.bearerScope ?? null) !== null; -} - -/** - * Require an authenticated request. Throws HttpError(401) / HttpError(403) on failure. - * - * Auth precedence (cookie-first, never both): - * 1. Valid session cookie → cookie path (existing behaviour, requiredPermission ignored). - * 2. No cookie + `Authorization: Bearer hlk_<...>` → API token path. - * 3. Neither → 401. - * - * v1.36.0 — and it REFUSES to run while the request is acting on another - * account. A bare `requireAuth()` is an undeclared route: nobody has decided - * whether it is safe under a switch, and the routes that call it include the - * password change, the MFA management surface, the token mint, and the - * integration connect. So a request carrying an acting-account carrier gets a - * 403 `sharing.not_permitted` here, and never a silent fall-back to the - * caller's own record. - * - * The fall-back is the tempting wrong answer and is worth naming: it looks - * harmless, it keeps every un-migrated route working, and it is the reverse - * data-mixing failure. A delegate reading what they believe is the owner's - * record would be shown their own; a delegate writing what they believe is the - * owner's measurement would write it into theirs. An inaccessible route is an - * inconvenience. A route that answers with the wrong person's health record is - * the product being wrong about the only thing it does. - * - * @param requiredPermission Optional permission scope. Only enforced for Bearer - * auth — cookie sessions always pass (full user access). Omitting it is a - * positive declaration: the route accepts cookie sessions and cookie- - * equivalent (`["*"]`) tokens ONLY, and refuses a narrow-scope token with - * HttpError(403). Naming a scope additionally admits narrow tokens that list - * it. A token that lists neither `*` nor the named scope throws - * HttpError(403). - */ -export async function requireAuth( - requiredPermission?: string, - options?: WriteMeterOptions, -): Promise { - const auth = await authenticateCaller(requiredPermission); - const carrier = await readActingCarrier(auth); - if (carrier.kind !== "none") { - throw refuseUndeclaredMode(auth, carrier); - } - // The shared write ceiling, for every write this resolver admits — see - // `meterAccountWrite`. - await meterAccountWrite(auth.user.id, options); - return auth; -} - -/** - * Resolve the caller as themselves. The pre-v1.36.0 `requireAuth` body, - * unchanged, split out so the three declared modes above it can share it. - * - * This function knows nothing about acting accounts and must not learn: it is - * the thing every mode agrees on before they disagree about what the request - * is allowed to reach. - */ -async function authenticateCaller( - requiredPermission?: string, -): Promise { - // 1. Session cookie path — unchanged. - const sessionData = await getSession(); - if (sessionData) { - const evt = getEvent(); - if (evt) { - evt.setAuth({ - user_id: sessionData.user.id, - user_role: sessionData.user.role, - auth_method: "session", - }); - } - return { ...sessionData, authMethod: "cookie" }; - } - - // 2. Bearer-token path. - // `headers()` is only valid inside a Next.js request scope. Outside one - // (e.g. during direct unit tests of legacy routes that pre-date Bearer auth) - // we treat the absence of a header context as "no Bearer present" and fall - // through to the unauthenticated case below. - let authHeader: string | null = null; - try { - const headerList = await headers(); - authHeader = headerList.get("authorization"); - } catch { - authHeader = null; - } - if (authHeader?.startsWith("Bearer ")) { - return await authenticateBearer(authHeader.slice(7), requiredPermission); - } - - // 3. No credentials. - throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing); -} - -/** - * Authenticate a raw Bearer token against `ApiToken`. - * - * The validation itself lives in the transport-agnostic `resolveBearerToken` - * (`./auth/bearer`) — the single source of truth shared with the MCP wire. This - * wrapper adds the HTTP-edge concerns: the `auth.bearer.failure` audit trail - * (only the failure path writes a durable audit row — the success row was - * intentionally dropped for perf) and the Wide-Event `auth_method: "bearer"` - * annotation, then maps the result onto the `AuthContext` contract - * (`session.id` carries the token id). - * - * Authorisation contract (fail-closed): a route that declares no - * `requiredPermission` accepts cookie sessions and cookie-equivalent (`["*"]`) - * tokens only — a narrow-scope token is refused 403 with an `undeclared_scope` - * audit row. A route that declares a scope additionally accepts narrow tokens - * that list it. The absence of an argument is a positive statement, not an - * omission, which is what makes the default safe for routes nobody has thought - * about yet. - */ -async function authenticateBearer( - rawToken: string, - requiredPermission: string | undefined, -): Promise { - const requirement: ScopeRequirement = requiredPermission - ? { kind: "scope", scope: requiredPermission } - : { kind: "wildcard-only" }; - let resolution; - try { - resolution = await resolveBearerToken(rawToken, requirement); - } catch (err) { - if (err instanceof BearerAuthError) { - auditLog("auth.bearer.failure", { - userId: err.userId ?? null, - details: { - reason: err.reason, - ...(err.tokenId ? { tokenId: err.tokenId } : {}), - ...(err.reason === "insufficient_permissions" - ? { required: requiredPermission } - : {}), - }, - }).catch(() => {}); - // Sentence and code are decided together, so a future rewording of the - // prose cannot silently change what a client does with the refusal. - const [message, errorCode] = - err.statusCode === 403 - ? (["Insufficient permissions", AUTH_ERROR_CODES.scope] as const) - : err.reason === "expired" - ? (["Token expired", AUTH_ERROR_CODES.expired] as const) - : (["Invalid token", AUTH_ERROR_CODES.invalid] as const); - throw new HttpError(err.statusCode, message, errorCode); - } - throw err; - } - - const { user, tokenId, expiresAt, permissions } = resolution; - - // H1 — audience binding at the resource server. An MCP-audience token - // (`health:read`, or `health:read health:write`) is bound to the `/mcp` - // surface; it may reach `/mcp` (a separate resolver that never runs this - // edge) and safe REST reads, but a write/delete over REST is outside its - // audience and is refused — INCLUDING a write-scoped token, whose writes are - // confined to the in-process `/mcp` tools and never granted over REST. Fail - // closed when the method is unknown - // (no event context) since every real REST request runs inside apiHandler, - // which always sets the method — an unknown method means we cannot prove a - // read, so we deny. This is RFC 8707 audience binding on the credential the - // client actually holds, not only during the OAuth exchange. - // - // Since the fail-closed scope default landed, this guard is unreachable on - // the deny path: an MCP-audience token carries no `*`, so `wildcard-only` - // already refused it in `resolveBearerToken` before we get here (an MCP - // token's REST reach is now nil, not "safe methods"). It is kept as defence - // in depth — it still holds the line if a future REST route ever declares - // `health:read` and so admits the token past the resolver. - if (isMcpAudienceToken(permissions)) { - const method = (getEvent()?.getHttpMethod() ?? "").toUpperCase(); - if (!READ_HTTP_METHODS.has(method)) { - auditLog("auth.bearer.failure", { - userId: user.id, - details: { - reason: "mcp_audience_write_blocked", - tokenId, - method: method || "unknown", - }, - }).catch(() => {}); - throw new HttpError( - 403, - "Insufficient permissions", - AUTH_ERROR_CODES.scope, - ); - } - } - - // v1.25 — no per-request success audit row. The polling iOS client drove a - // constant INSERT + pool checkout on every authenticated Bearer request; the - // wide event below already records `auth_method: "bearer"` + `user_id`, so the - // success path stays fully observable without the write churn. The failure - // path keeps its audit row. - const evt = getEvent(); - if (evt) { - evt.setAuth({ - user_id: user.id, - user_role: user.role, - auth_method: "bearer", - }); - } - - return { - session: { id: tokenId, expiresAt }, - user, - authMethod: "bearer", - // Non-null exactly when a NAMED scope admitted a narrow token. A `["*"]` - // token is cookie-equivalent and reads null however the route was declared; - // a narrow token on a route that named no scope never reaches this line, - // because `wildcard-only` already refused it in `resolveBearerToken`. - // - // Derived here rather than in the resolver so `bearer.ts` keeps its single - // authorisation arm: this is a report of the decision that already ran, not - // a second one. - bearerScope: permissions.includes("*") - ? null - : (requiredPermission ?? null), - }; -} - -// ── The acting-account resolver ───────────────────────────────────────────── -// -// One function decides, for every transport, whether this request acts on -// somebody else's record — the same argument that put `resolveBearerToken` in -// one file for two wires. Two carriers converge on one decision because they -// are two answers to one question, and two answers that live apart drift. -// -// That decision now lives in `./auth/acting-carrier`, because a second caller -// needs it: the idempotency wrapper files its replay cell under the record a -// request claims, and it runs before any auth context exists. What stays here -// is what only this file may do — turning a claim into a data scope after a -// live grant says so. +// The guards live under `./auth/guards/`, one module per family; this module +// is their one public entry, so a route imports the wrapper and its guard +// from the same place. +export { + isMcpAudienceToken, + isScopedCredential, + type AuthContext, +} from "./auth/guards/context"; +export { + requireAuth, + requireBearerAuth, + type BearerAuthContext, +} from "./auth/guards/caller"; +export { + requireActorAuth, + requireGuardianAuth, + requireRecordAuth, + type RecordAuthContext, + type RecordAuthOptions, +} from "./auth/guards/record"; +export { requireAdmin, requireCookieAuth } from "./auth/guards/cookie"; +export { + MFA_STEP_UP_MAX_AGE_SECONDS, + type FreshFactorProofSource, +} from "./auth/guards/fresh-factor"; +export { + requireFreshMfa, + requireFreshMfaIfEnrolled, + requireFreshMfaOrElevationIfEnrolled, + type ErasureAuthContext, + type FreshMfaContext, +} from "./auth/guards/fresh-mfa"; +export { + assertRecentCookieProof, + requireRecentProof, + type RecentProofBearerRule, +} from "./auth/guards/recent-proof"; +export { STEP_UP_ELEVATION_HEADER } from "./auth/guards/elevation"; +export { + requireMfaManagementAuth, + type MfaManagementContext, +} from "./auth/guards/mfa-management"; /** * The selector header's name, re-exported. @@ -752,1167 +432,6 @@ async function authenticateBearer( */ export { ACCOUNT_SELECTOR_HEADER } from "./auth/acting-carrier"; -/** - * The acting-account carrier for this request. - * - * The session value rides out of `getSession()` on the row that call already - * loaded — a second query for a column we have just read would be waste on - * every cookie request in the app. What does NOT ride out of `getSession()` is - * a substituted user: it keeps answering "who is calling" and nothing else, - * which is exactly why `requireAdmin`, `requireCookieAuth` and - * `requireFreshMfa` are unreachable by a switch. The carrier is an id to be - * checked, not a decision that has been made. - * - * Fresh on every request, by construction: `getSession()` reads the row from - * Postgres each time, so a switch cleared by a revocation is gone from the very - * next request rather than from the next login. - */ -async function readActingCarrier(auth: AuthContext): Promise { - return decideActingCarrier({ - transport: auth.authMethod === "bearer" ? "bearer" : "cookie", - stamped: auth.session.actingAsUserId ?? null, - header: await readSelectorHeader(), - }); -} - -/** - * Record a refused delegation. - * - * The row is filed under the CALLER, with no actor, and it is the operator's - * trail rather than the owner's. It names who called, the account they claimed, - * and why the claim was refused — none of which reaches the response. - * - * It is deliberately not filed under the claimed account, and that decision is - * worth stating because the opposite reads as the obvious one. A row is only - * written for a HEADER refusal, and a header is an unverified per-request - * assertion: the value is whatever the caller typed. Filing under it would let - * any authenticated caller write rows into any account's activity feed by - * naming it, which is a spam vector aimed at exactly the panel that exists to - * be trustworthy. Verifying the claim first would mean a grant lookup on a path - * whose whole job is to refuse without touching the record. - * - * What the owner does see is the other half: `recordDelegatedAccess` files a - * per-day row under the OWNER, naming the actor, for every delegated read that - * succeeded. "Who reached my record" is answered there. "Whose client sent a - * malformed claim" is an operator question and stays here. - * - * Fire-and-forget: an audit write that fails must not convert a 403 into a 500, - * because the 403 is the part that protects the record. - */ -function auditRefusal( - auth: AuthContext, - carrier: ActingCarrier, - reason: string, -): void { - auditLog("sharing.access.denied", { - userId: auth.user.id, - details: { - reason, - carrier: carrier.kind, - target: carrierTarget(carrier), - method: getEvent()?.getHttpMethod() ?? null, - }, - }).catch(() => {}); -} - -/** - * This request says it is acting on another account and this route will not. - * - * Three ways to arrive: a route that never declared a mode (`undeclared_mode`), - * a selector sent over the transport that does not carry one - * (`misplaced_selector`), and a narrow single-purpose Bearer naming a record - * that is not its owner's (`narrow_scope_selector`). One response every way — - * the caller learns that the request will not be served, and nothing about - * grants. - * - * The audit rule, stated once because it is a judgement call: a HEADER refusal - * always writes a row, a SESSION refusal never does. The header is a per-request - * assertion by a client — a bug or a probe, and both are worth a durable record. - * The session field is ambient browser state: while a switch is on, every - * navigation, prefetch and poll that touches a non-delegable route lands here, - * and a row per such request would bury the header rows that matter under noise - * from the feature working as designed. Both are on the wide event either way. - */ -function refuseUndeclaredMode( - auth: AuthContext, - carrier: ActingCarrier, - reason: - | "undeclared_mode" - | "misplaced_selector" - | "narrow_scope_selector" = "undeclared_mode", -): SharingNotPermittedError { - annotate({ meta: { sharing_refusal: reason } }); - if (carrier.kind !== "session") { - auditRefusal(auth, carrier, reason); - } - return new SharingNotPermittedError(); -} - -/** - * v1.36.0 — an actor surface: this route always serves the CALLER's own rows. - * - * It works under a switch. That is the whole reason the mode exists: the - * switcher itself, the banner, and the "which records may I open" list all have - * to keep answering while the browser is acting as someone else, and every one - * of them is about the delegate. So the session carrier is read and - * deliberately ignored — no query, because there is nothing the answer could - * change. - * - * A selector HEADER is refused, loudly. An actor surface is DEFINED as serving - * the caller's own rows, so a client that attaches a selector to one has a bug, - * and the bug is invisible until somebody files a support ticket about data - * that looks wrong. Ignoring the header would serve exactly the right response - * to exactly the wrong expectation. - */ -export async function requireActorAuth(): Promise { - const auth = await authenticateCaller(); - const header = await readSelectorHeader(); - if (header !== null) { - annotate({ meta: { sharing_refusal: "actor_surface" } }); - auditRefusal(auth, { kind: "header", accountId: header }, "actor_surface"); - throw new SharingNotPermittedError(); - } - await meterAccountWrite(auth.user.id); - return auth; -} - -/** - * A request resolved against a record that may not be the caller's own. - * - * `user` is the account whose rows the handler must read and write — so an - * existing `where: { userId: user.id }` is correct without being touched, which - * is what keeps a route migration a one-line change instead of an audit. - * `actor` is who is actually here. They are the same object when no switch is - * active. - */ -export interface RecordAuthContext extends AuthContext { - /** The authenticated caller. Equals `user` when the caller acts as themselves. */ - readonly actor: SessionUser; - /** The grant being exercised, or null when the caller acts as themselves. */ - readonly grantId: string | null; -} - -/** What a delegable route may additionally declare about the credentials it takes. */ -export interface RecordAuthOptions { - /** - * A Bearer scope this route accepts in place of the cookie-equivalent - * default. Naming one is a WIDENING — it admits a credential class the route - * refused before — and is frozen by `bearer-scope-enforcement-guard.test.ts` - * exactly like a `requireAuth` scope, so it lands in a reviewed diff. - * - * What the option does NOT do is relax anything below it. A credential - * admitted this way is single-purpose: it reaches this route on its OWN - * record and is refused the moment the request names another, before any - * grant is read. The `need` and `domain` checks run unchanged for everyone - * else. - */ - readonly scope?: string; - /** - * v1.42.2 — this route answers a POST without changing anything: the body - * carries a query that must not stand in a URL (the record search, whose - * text is health data a proxy's access log would otherwise keep). Under a - * route declaring `"read"`, a POST is then read at the grant's read level - * instead of being escalated to a write. - * - * Like `scope`, naming it is a WIDENING and is frozen by - * `read-over-post-guard.test.ts`, so it lands in a reviewed diff. It does - * nothing for any other method, and nothing for a route declaring a need - * above `"read"`. - */ - readonly readOverPost?: true; - /** - * This route meters its writes with a dedicated bucket instead of the shared - * per-account one (`meterAccountWrite`). Frozen per route, with the reason, - * by `write-rate-limit-guard.test.ts`, which also requires the route to call - * its own limiter. - */ - readonly writeBucket?: "own"; -} - -/** - * v1.36.0 — a delegable surface: this route may act on a shared record. - * - * Calling it is a DECLARATION, reviewed and frozen by a structural guard, that - * the route reads or writes health data and nothing else — no credential, no - * integration, no notification channel, no grant management, nothing that would - * let a delegate extend their own reach. The declaration is the security - * property; this function only enforces what was declared. - * - * @param need what the route does to the record. `"read"` still refuses a - * non-safe HTTP method under a read-only grant: the declaration and the - * method must BOTH be satisfiable, so a delegable GET handler that grows a - * POST export cannot quietly inherit the read grant's permission. An unknown - * method (no event context) counts as a write and is refused, the same - * fail-closed posture as the MCP audience guard above. A route declaring - * `"manage"` refuses everything below it regardless of method: the method - * can only escalate what the declaration asked for, never satisfy it. - * @param domain v1.37.0 — which section of the record the route touches, or - * `"record"` when it reads across sections. Required, and the requirement is - * the fail-closed lever: a delegable route without a classification does not - * typecheck, so the set of routes carrying one cannot fall behind the set of - * routes that are delegable. The value is frozen per module by - * `src/__tests__/sharing-surface-guard.test.ts` and reviewed against the - * design's clustering table; this function only enforces what was declared. - * @param options v1.38.x — optional extra declarations. Today that is `scope`, - * naming a narrow Bearer this route admits alongside cookie sessions and - * cookie-equivalent tokens. A route that passes none keeps the fail-closed - * default and refuses every narrow token, which is what makes adding one a - * visible decision rather than an omission. - */ -export async function requireRecordAuth( - need: GrantNeed, - domain: ShareScope, - options?: RecordAuthOptions, -): Promise { - const auth = await authenticateCaller(options?.scope); - // v1.37.0 — before any carrier is read, any grant is looked up, or any record - // row is touched: does this request still believe what its session believes? - // - // The atomicity that makes this a tautology rather than a race: the epoch the - // fence compares and the `actingAsUserId` the carrier below is built from are - // BOTH projected off the one session row `authenticateCaller()` already - // loaded. The scope this handler serves under and the epoch it validated are - // therefore the same fact read at the same instant, not two reads a commit - // can land between. Do not add a second session read here to "refresh" it — - // that would reintroduce exactly the window this ordering removes. - // - // `authenticateCaller` necessarily runs first because it supplies the row the - // fence reads, so the invariant is "before the carrier", not "before the - // first database await". - await assertRecordSessionFence(auth); - // The shared write ceiling, charged to the ACTING account before any grant - // is read, so a refused write counts like an admitted one — see - // `meterAccountWrite`. - await meterAccountWrite(auth.user.id, { - readOverPost: options?.readOverPost, - writeBucket: options?.writeBucket, - }); - const carrier = await readActingCarrier(auth); - - // A narrow, single-purpose Bearer never acts on another record. - // - // Its holder mints it themselves, for one job, and pastes it into something - // that is not this app — a scale bridge, a home-automation rule. Admitting it - // under a selector would exercise somebody else's grant through a credential - // nobody reviewed for that purpose, and the blast radius of the paste would - // stop being "my own record" without anyone deciding it should. - // - // Placed above the `none` arm and below the carrier read, which is the only - // position that is both correct and cheap: no grant is looked up on the way - // to the refusal, and a scoped credential on its own record still falls - // through to the untouched `none` path below. A `["*"]` token and a cookie - // session read `bearerScope: null` here and keep exactly the delegation - // behaviour they had. - if (carrier.kind !== "none" && isScopedCredential(auth)) { - throw refuseUndeclaredMode(auth, carrier, "narrow_scope_selector"); - } - - if (carrier.kind === "none") { - // Do no harm: without a carrier this is byte-for-byte the pre-v1.36.0 - // request, resolved by the same `authenticateCaller` every other mode uses. - getEvent()?.setProviderWorkAuthority({ - origin: "owner", - recordUserId: auth.user.id, - actorUserId: auth.user.id, - grantId: null, - }); - return { ...auth, actor: auth.user, grantId: null }; - } - if (carrier.kind === "misplaced-header") { - // The route DID declare a mode; the client sent the selector over the - // cookie transport, where the carrier is the session row. - throw refuseUndeclaredMode(auth, carrier, "misplaced_selector"); - } - - const method = (getEvent()?.getHttpMethod() ?? "").toUpperCase(); - const readsOnly = - READ_HTTP_METHODS.has(method) || - (options?.readOverPost === true && method === "POST"); - const effectiveNeed: GrantNeed = - need !== "read" || !readsOnly ? escalate(need) : "read"; - - return resolveSwitchedRecord(auth, carrier, (grant) => { - if (!grantAllows(grant, effectiveNeed)) return "insufficient_access"; - // Level first, then scope, and the order is visible on the wire in exactly - // one way: not at all. Both refusals are the same error with the same - // bytes, and only the audit reason differs — so the ordering is a choice - // about what the operator's trail says happened, not about what the caller - // learns. It says the bigger thing: a delegate who was never given this - // level is a different story from one whose sections do not reach here. - if (!grantCoversDomain(grant, domain)) return "out_of_scope"; - return null; - }); -} - -/** - * The need a non-safe method implies, for a route that declared `need`. - * - * A PUT on a route declaring `"read"` needs a write grant, which is what this - * has always done. A PUT on a route declaring `"manage"` needs a MANAGE grant - * and must not be reduced to a write — so the escalation raises the floor to - * `"write"` and never lowers a declaration that already sits above it. - */ -function escalate(need: GrantNeed): GrantNeed { - return need === "manage" ? "manage" : "write"; -} - -/** - * v1.37.0 — a guardian surface: this route administers a managed profile. - * - * The other declaration, and a separate function rather than a flag on the one - * above, for the reason `requireActorAuth` is also separate: the guard freezes - * each declaration as its own list, so a route moving between them is a diff a - * human reviews rather than an argument about a parameter. - * - * What it admits is narrower than it looks. An active MANAGE grant is - * necessary and NOT sufficient — the record must also carry the managed-profile - * marker. That second condition is the identity fence, and it is the line - * between "manage my data" and "own my account": an adult who granted - * management of their own record reaches none of this, at any level, by any - * argument, because their record has no marker and never will while they hold - * it. A managed profile has no self to reserve these surfaces for, so its - * guardian holds them. - * - * The fence is gated on the marker rather than on the grant precisely so that - * no future change to what MANAGE means can reach through it. Widening MANAGE - * would widen `requireRecordAuth`; it would not touch this function. - */ -export async function requireGuardianAuth(): Promise { - const auth = await authenticateCaller(); - // Same placement and the same reason as `requireRecordAuth` above: the - // guardian surfaces administer a managed profile, so a request arriving under - // a context that has since moved must be refused before the marker is read, - // not after. - await assertRecordSessionFence(auth); - await meterAccountWrite(auth.user.id); - const carrier = await readActingCarrier(auth); - - if (carrier.kind === "none") { - // The owner of an ordinary record, reaching their own settings. Same - // do-no-harm posture as the record resolver: without a carrier this is the - // request it always was. - return { ...auth, actor: auth.user, grantId: null }; - } - if (carrier.kind === "misplaced-header") { - throw refuseUndeclaredMode(auth, carrier, "misplaced_selector"); - } - - return resolveSwitchedRecord(auth, carrier, (grant, owner) => { - if (!grantAllows(grant, "manage")) return "insufficient_access"; - if (owner.managedProfileAt === null) return "guardian_only"; - return null; - }); -} - -/** - * The shared body of both record resolvers: turn a selector into an owner, or - * refuse. - * - * Everything either resolver does with a carrier lives here except the - * admission rule itself, which arrives as `admit` — a function of the grant - * and the owner returning an audit reason to refuse with, or null to proceed. - * The split is where it is because the two resolvers differ in exactly that - * one decision and in nothing else, and two copies of the grant lookup, the - * refusal shape, the usage stamp and the owner's access row would be two - * copies to keep in step. - * - * The owner row is loaded BEFORE the admission runs, which is a change from - * v1.36.0's ordering and a deliberate one: the guardian rule is a fact about - * the owner, so it cannot be decided before the owner is known. Nothing about - * the refusal moved — every arm below is the same error with the same bytes, - * and an admission that refuses after the load costs one query it does not use - * and tells the caller nothing extra. - */ -async function resolveSwitchedRecord( - auth: AuthContext, - carrier: Extract, - admit: (grant: AccountGrant, owner: SessionUser) => string | null, -): Promise { - /** Every refusal below is this one error, with the reason kept off the wire. */ - const denied = (reason: string): SharingAccessDeniedError => { - annotate({ meta: { sharing_refusal: reason } }); - auditRefusal(auth, carrier, reason); - return new SharingAccessDeniedError(); - }; - - if (!selectorNamesAnAccount(carrier.accountId)) { - throw denied("malformed_selector"); - } - - // Loaded here, on this request, every request. Not memoised on the session, - // not cached in the process, not carried on the token: a revocation has to - // land on the delegate's NEXT request, not on their next login. That is the - // one property the owner is actually promised when they press revoke. - // - // Note what is NOT looked up: the account named by the carrier. An account - // that does not exist and an account that granted nothing produce the same - // empty row from the same query, which is what makes the two refusals - // identical in bytes and in timing rather than identical by careful wording. - const grant = await findActiveGrant({ - grantorId: carrier.accountId, - granteeId: auth.user.id, - }); - if (!grant) throw denied("no_active_grant"); - - const owner = await prisma.user.findUnique({ - where: { id: grant.grantorId }, - select: SESSION_USER_SELECT, - }); - // Unreachable while the FK cascade holds — a grant naming a deleted account - // is deleted with it. Fail closed anyway: an authorization we cannot resolve - // to a person is not one to act on. - if (!owner) throw denied("owner_missing"); - - const refusal = admit(grant, owner); - if (refusal !== null) throw denied(refusal); - - getEvent()?.setActingAs(owner.id); - // v1.37.0 — and whether this request may spend the owner's provider budget. - // MANAGE opens the generated reads; it does not open the generation behind - // them, because the owner would pay for and consent to an egress they did - // not cause. A managed profile is the exception and the reason the marker is - // read here rather than the grant: it has no self to protect from its own - // guardian. Stamped where the owner row is already loaded, so no generator - // has to be told; see `src/lib/sharing/delegated-generation.ts`. - if (owner.id !== auth.user.id && owner.managedProfileAt === null) { - getEvent()?.setDelegatedGenerationSuppressed(); - } - getEvent()?.setProviderWorkAuthority({ - origin: owner.managedProfileAt === null ? "delegate" : "guardian", - recordUserId: owner.id, - actorUserId: auth.user.id, - grantId: grant.id, - }); - // Fire-and-forget, the `ApiToken.lastUsedAt` posture: the read must not wait - // on the bookkeeping, and the bookkeeping failing must not fail the read. - void touchGrantUsage(grant.id); - // The owner's copy of the same fact, and the one they can read: a day- - // coalesced row on their own record saying who opened it. It lands here - // rather than in the handlers because a route cannot forget what it never - // had to remember — every delegated request in the product passes through - // this line, and only requests that got past the grant check reach it. - void recordDelegatedAccess(owner.id, auth.user.id); - - return { - session: auth.session, - user: owner, - authMethod: auth.authMethod, - actor: auth.user, - grantId: grant.id, - }; -} - -/** - * Require an authenticated admin user. Throws HttpError(401) or HttpError(403). - * Cookie-only — Bearer tokens never elevate to admin (security boundary). - * Automatically annotates the Wide Event with auth context. - * - * v1.36.0 — unreachable by a switch, structurally: it resolves through - * `getSession()`, which knows only who is calling, and the role is read off - * that row. Switching into an ADMIN's record therefore confers nothing, and - * making it confer something would take a deliberate edit here rather than an - * oversight elsewhere. - */ -export async function requireAdmin(): Promise { - const sessionData = await getSession(); - if (!sessionData) - throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing); - - const evt = getEvent(); - if (evt) { - evt.setAuth({ - user_id: sessionData.user.id, - user_role: sessionData.user.role, - auth_method: "session", - }); - } - - if (sessionData.user.role !== "ADMIN") { - throw new HttpError(403, "Admin access required", AUTH_ERROR_CODES.admin); - } - await meterAccountWrite(sessionData.user.id); - return { ...sessionData, authMethod: "cookie" }; -} - -/** - * v1.23 — require a cookie-backed session, refusing Bearer tokens. - * - * Cookie-only by the same structural argument as `requireAdmin`: it resolves - * the session via `getSession()` (which reads only the session cookie) and - * never falls through to the Bearer branch. The second-factor management - * surfaces (TOTP enroll / confirm / disable / recovery-code regenerate) use - * this so an API token — even a wildcard one — can never enrol or tear down - * MFA on the account it belongs to. MFA management is a browser-only action. - */ -export async function requireCookieAuth(): Promise { - const sessionData = await getSession(); - if (!sessionData) - throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing); - - const evt = getEvent(); - if (evt) { - evt.setAuth({ - user_id: sessionData.user.id, - user_role: sessionData.user.role, - auth_method: "session", - }); - } - await meterAccountWrite(sessionData.user.id); - return { ...sessionData, authMethod: "cookie" }; -} - -/** - * Default step-up freshness window (5 minutes) for sensitive mutations. - * Within the 5–15 min band OWASP recommends; tight end because the gated - * actions (disable MFA, regenerate codes, and later key rotation / export) - * are destructive. - */ -export const MFA_STEP_UP_MAX_AGE_SECONDS = 5 * 60; - -export type FreshMfaContext = AuthContext & { mfaVerifiedAt: Date }; - -/** - * Which credentials count when asking "can this account produce a fresh-factor - * proof at all". - * - * The gate is the same either way — a `Session.mfaVerifiedAt` inside the window - * on the cookie path, an elevation minted from a `FRESH_FACTOR_METHODS` proof on - * the Bearer path. What differs is the pre-check that decides whether refusing - * would be a gate or a dead end, and the honest answer to that depends on what - * the route is protecting. - * - * `second-factor` — a confirmed TOTP secret or a registered second-factor - * security key. The default of `requireMfaManagementAuth`, and the right - * question for the routes that MANAGE a second factor: there is nothing there - * to protect if nothing is enrolled. - * - * `any-possession` — the above, OR a registered PRIMARY passkey. The default - * of `requireFreshMfa` since v1.43.3, and the right question for a route - * protecting the passkey itself, because a passkey is a - * possession factor in its own right and this codebase already treats it as - * one in three places: `/api/auth/passkey/login-verify` stamps `mfaVerifiedAt` - * on a passkey login (v1.23, M-review M1), `passkey` is in - * `FRESH_FACTOR_METHODS` so a passkey-proved elevation reaches the fresh-factor - * routes, and `resolveMfaEnrollmentRequired` counts a primary passkey as - * satisfying the enforcement policy outright. A passkey-only account can - * therefore clear the gate on both transports, and asking the narrower - * question would have thrown `mfa_not_enrolled` at an account that was holding - * the proof all along. - */ -export type FreshFactorProofSource = "second-factor" | "any-possession"; - -/* - * A PASSKEY COUNTS AS A SECOND FACTOR FOR THE UNCONDITIONAL STEP-UP GATE. - * - * Maintainer decision, v1.43.3. `requireFreshMfa` defaults to - * `any-possession`, so an account whose only strong credential is a passkey - * is no longer refused `mfa_not_enrolled` on the routes that call it directly - * (creating, editing, deleting and handing over a managed profile, inviting - * and removing a guardian, the admin key rotation). It clears the gate the - * way every other account does: a passkey sign-in or a passkey re-proof at - * `POST /api/auth/reproof` stamps `Session.mfaVerifiedAt`, and the gate reads - * that stamp inside the same five minutes. - * - * The limits, each deliberate: - * - * - `hasSecondFactorEnrolled` keeps its narrow meaning. It decides whether - * the CONDITIONAL gates (`requireFreshMfaIfEnrolled`, - * `requireFreshMfaOrElevationIfEnrolled`, `requireRecentProof`'s branches, - * the enrollment proofs) apply at all. Widening it would newly gate - * account deletion, the data reset, the password change and the export - * for every passkey holder, take the password re-proof away from them, and - * change what the native app's Bearer requests need. None of that is part - * of the decision. - * - Password login is untouched. It asks for a second factor from - * `totpConfirmedAt` and the security-key count only; a passkey-only - * account signing in with its password is not challenged, as before. - * - The MFA-management routes (`requireMfaManagementAuth`) still default to - * `second-factor`: disabling TOTP or rotating recovery codes on an account - * with no second factor has nothing to protect. - * - Cookie only, as ever. The Bearer path is not reached from here. - */ - -/** - * v1.23 — step-up gate. Passes only for a COOKIE session whose - * `Session.mfaVerifiedAt` is within `maxAgeSeconds` AND whose user holds a - * credential that can produce such a stamp. Throws `StepUpRequiredError` - * (401, `errorCode: "auth.stepup.required"`) otherwise. - * - * Bearer tokens can NEVER satisfy this — exactly like `requireAdmin`, the - * resolution path is `getSession()` (cookie-only) and there is no Bearer - * fall-through. A token transport carries no `mfaVerifiedAt` and cannot - * acquire one, so the boundary is structural, not a softenable runtime check. - * - * Consumed in Phase M by MFA disable + recovery-code regeneration; later - * waves gate account deletion, key rotation, and passphrase export on it. - * - * @param proofSource which credentials count as "can prove a factor". Defaults - * to `any-possession`, so a primary passkey counts (see the decision note - * below {@link FreshFactorProofSource}); `requireMfaManagementAuth` passes - * `second-factor` explicitly. - */ -export async function requireFreshMfa( - maxAgeSeconds: number, - proofSource: FreshFactorProofSource = "any-possession", -): Promise { - const sessionData = await getSession(); - if (!sessionData) - throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing); - - const evt = getEvent(); - if (evt) { - evt.setAuth({ - user_id: sessionData.user.id, - user_role: sessionData.user.role, - auth_method: "session", - }); - } - - await meterAccountWrite(sessionData.user.id); - - // The account must hold a credential that can stamp `mfaVerifiedAt` in the - // first place. Without one a step-up-gated action is unreachable by design - // rather than merely refused, so the refusal is named differently - // (`mfa_not_enrolled`) and the management UI gates enrolment ahead of it. - if (!(await canProveFreshFactor(sessionData.user, proofSource))) { - throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled"); - } - - // Read the freshness stamp off the live session row — `getSession`'s - // projection intentionally omits it. - const row = await prisma.session.findUnique({ - where: { id: sessionData.session.id }, - select: { mfaVerifiedAt: true }, - }); - const verifiedAt = row?.mfaVerifiedAt ?? null; - if (!verifiedAt || verifiedAt.getTime() < Date.now() - maxAgeSeconds * 1000) { - // Name the proofs that clear this gate, the way the recent-proof gate - // does, so the browser can open the re-proof dialog instead of printing a - // sentence with nowhere to act on it. Only the possession proofs: a - // `POST /api/auth/reproof` with one of them stamps `mfaVerifiedAt`, a - // password stamps `reproofAt` only and would leave this gate shut. - const methods = (await recentProofMethods(sessionData.user)).filter( - (method) => method !== "password", - ); - annotate({ meta: { step_up_methods: methods.join(",") } }); - throw new StepUpRequiredError( - "auth.stepup.required", - "Recent second-factor verification required", - { methods }, - ); - } - - return { ...sessionData, authMethod: "cookie", mfaVerifiedAt: verifiedAt }; -} - -/** - * v1.23 — conditional step-up for destructive account actions. - * - * Resolves the caller with the standard `requireAuth()` (cookie OR Bearer). - * For an account WITHOUT a confirmed second factor the caller passes straight - * through — a single-factor user is intentionally unaffected, so account - * deletion / data reset keeps its existing typed-confirmation-only contract. - * For an account WITH MFA active (`totpConfirmedAt` set) it additionally runs - * `requireFreshMfa`, which is cookie-only by construction: an MFA-enrolled - * account's Bearer transport carries no `mfaVerifiedAt` and therefore cannot - * satisfy step-up, surfacing `StepUpRequiredError` (401, - * `errorCode: "auth.stepup.required"`) so the UI launches a re-verification. - * - * Gating only the MFA-enrolled cohort keeps the boundary structural: a - * hijacked live cookie session for an MFA user cannot nuke the record without - * a fresh factor, while users who never opted into MFA are not forced through - * a flow they have no way to complete. - */ -export async function requireFreshMfaIfEnrolled( - maxAgeSeconds: number, -): Promise { - const auth = await requireAuth(); - if (await hasSecondFactorEnrolled(auth.user)) { - await requireFreshMfa(maxAgeSeconds); - } - return auth; -} - -/** - * What an erasure route received: the caller, plus the hook that spends a - * Bearer elevation. See `MfaManagementContext.commitElevation` for the - * contract — call it immediately before the mutation, after every cheap - * refusal (a bad confirmation, the last-admin check) has passed, so a 422 - * does not burn a proof the user then has to mint again. - */ -export type ErasureAuthContext = AuthContext & { - commitElevation: () => Promise; -}; - -/** - * The sibling of `requireFreshMfaIfEnrolled` for the two erasure routes — - * account deletion and the record wipe — and only those. - * - * WHY IT EXISTS. App Store guideline 5.1.1(v) requires that an account created - * in the app can be deleted from the app. `requireFreshMfaIfEnrolled` gates an - * MFA-enrolled account on `requireFreshMfa`, which is cookie-only by - * construction, so a native-app user with a second factor could not delete - * their account from the app at all: the Bearer transport had no way to - * present a fresh factor and the route answered 401 "Not authenticated" to a - * perfectly valid token. This helper adds the Bearer arm without touching - * `requireFreshMfa`, which stays cookie-only. - * - * THE THREE ARMS. - * - * No second factor enrolled — pass through on either transport, exactly as - * the sibling does. A user who never enrolled has no ceremony to complete. - * - * Cookie + enrolled — `requireFreshMfa`, unchanged: `Session.mfaVerifiedAt` - * within the window. A browser session cannot tell the two helpers apart. - * - * Bearer + enrolled — a step-up elevation in the `X-Step-Up` header, minted - * by `POST /api/auth/step-up` against a re-proved factor, validated by the - * same code the MFA-management routes use and with `freshFactor` pinned ON: - * a `totp`, `webauthn`, or `passkey` proof passes; a password proof is - * refused with the same generic 401, because a password login has never - * satisfied step-up on the web and must not start here. The elevation is - * token-bound, single-use, and five minutes wide — the cookie window. - * - * WHAT IT DOES NOT DO. Scope is `requireAuth()`'s fail-closed default, so a - * narrow token is refused 403 before the header is read. A recovery code is - * not accepted on Bearer — the mint does not take one, and `FRESH_FACTOR_METHODS` - * says why; an account that has lost its authenticator erases itself on the - * web, where `/api/auth/mfa/verify` accepts the code and stamps the session. - * And the gate takes no factor in the BODY: there is one mint surface and one - * redemption path, which is what `step-up-elevation-guard.test.ts` freezes. - */ -export async function requireFreshMfaOrElevationIfEnrolled( - maxAgeSeconds: number, -): Promise { - const auth = await requireAuth(); - const noop = async () => {}; - - if (!(await hasSecondFactorEnrolled(auth.user))) { - return { ...auth, commitElevation: noop }; - } - - if (auth.authMethod === "cookie") { - await requireFreshMfa(maxAgeSeconds); - return { ...auth, commitElevation: noop }; - } - - // On the Bearer path `requireAuth` puts the resolved `ApiToken` row id in - // `session.id` — the binding the elevation was minted against. - const commitElevation = await resolveBearerElevation( - { user: auth.user, apiTokenId: auth.session.id }, - { freshFactor: true, proofSource: "second-factor" }, - ); - return { ...auth, commitElevation }; -} - -/** - * How the recent-proof gate treats a Bearer caller. - * - * `elevation` — the Bearer twin of the cookie rule: an `X-Step-Up` elevation - * minted by `POST /api/auth/step-up`. On an account with a second factor it - * must come from a second-factor proof; without one, a password-proved - * elevation is enough. For routes no shipped client calls on Bearer. - * - * `elevation-if-enrolled` — the erasure routes' rule - * (`requireFreshMfaOrElevationIfEnrolled`): an account with a second factor - * presents a second-factor elevation, one without passes on the token. For a - * route the shipped app calls, where the second-factor arm was already - * refused before and the other arm must keep working. - * - * `token` — the token is the proof, as before. Only for routes the shipped - * app calls without an elevation, where a refusal would sign the app out (it - * reads a 401 outside its known paths as a dead session). Each one is named - * in the iOS note and in `recent-proof-surface-guard.test.ts`, so tightening - * it later is a visible edit. - */ -export type RecentProofBearerRule = - "elevation" | "elevation-if-enrolled" | "token"; - -/** - * The fresh-proof gate in front of the actions that hand the record, or a - * standing credential to it, to somebody: exporting the whole record, making a - * clinician share link, minting a token, and the admin backup, restore, wipe - * and reset actions. A live session is not enough for those, because whoever - * holds a stolen session would otherwise walk off with the record or leave a - * credential behind that outlives the session. - * - * Cookie session, account WITH a second factor: `Session.mfaVerifiedAt` - * within five minutes — exactly `requireFreshMfa`. Signing in completes the - * factor; later, `POST /api/auth/reproof` with a TOTP code, security key or - * passkey stamps it again. - * - * Cookie session, account WITHOUT a second factor: any of the session's - * creation (a sign-in), `mfaVerifiedAt` (a passkey sign-in or re-proof) or - * `reproofAt` (a password re-proof) within five minutes. Such an account - * re-enters its password, or uses a passkey. - * - * The refusal is 401 `auth.reproof.required` with `meta.methods`, the proofs - * this account can give; the web opens the re-proof dialog on it and retries. - * An empty list means the account holds nothing to re-prove in place (an - * SSO-only account), and signing in again is the way through. - * - * Returns the caller plus `commitElevation`, which spends a Bearer elevation - * and is a no-op otherwise. Call it right before acting. - */ -export async function requireRecentProof(options: { - bearer: RecentProofBearerRule; -}): Promise { - const auth = await requireAuth(); - const noop = async () => {}; - - if (auth.authMethod === "cookie") { - await assertRecentCookieProof(auth.user, auth.session.id); - return { ...auth, commitElevation: noop }; - } - - const enrolled = await hasSecondFactorEnrolled(auth.user); - if (options.bearer === "token") { - annotate({ meta: { recent_proof: "bearer_token" } }); - return { ...auth, commitElevation: noop }; - } - if (options.bearer === "elevation-if-enrolled" && !enrolled) { - annotate({ meta: { recent_proof: "bearer_token" } }); - return { ...auth, commitElevation: noop }; - } - const commitElevation = await resolveBearerElevation( - { user: auth.user, apiTokenId: auth.session.id }, - { freshFactor: enrolled, proofSource: "second-factor" }, - ); - return { ...auth, commitElevation }; -} - -/** - * The cookie arm of {@link requireRecentProof}, for routes that resolve the - * caller themselves (the admin routes, which must stay on `requireAdmin`). - */ -export async function assertRecentCookieProof( - user: SessionUser, - sessionId: string, -): Promise { - const enrolled = await hasSecondFactorEnrolled(user); - const row = await prisma.session.findUnique({ - where: { id: sessionId }, - select: { mfaVerifiedAt: true, createdAt: true, reproofAt: true }, - }); - const now = Date.now(); - const fresh = (d: Date | null | undefined) => - d != null && now - d.getTime() <= MFA_STEP_UP_MAX_AGE_SECONDS * 1000; - const satisfied = enrolled - ? fresh(row?.mfaVerifiedAt) - : fresh(row?.mfaVerifiedAt) || - fresh(row?.createdAt) || - fresh(row?.reproofAt); - if (satisfied) { - annotate({ meta: { recent_proof: "session" } }); - return; - } - const methods = await recentProofMethods(user, enrolled); - annotate({ - action: { name: "auth.reproof.required" }, - meta: { second_factor: enrolled, methods: methods.join(",") }, - }); - throw new StepUpRequiredError( - REPROOF_REQUIRED_CODE, - "Confirm it is you to continue", - { methods }, - ); -} - -/** - * Can this account produce a fresh-factor proof at all? - * - * The wider question, asked by the routes that gate on possession rather than - * on second-factor management. See {@link FreshFactorProofSource} for why a - * primary passkey answers yes and where the codebase already says so; see - * {@link hasSecondFactorEnrolled} for why the narrower predicate must not be - * widened into this one. - */ -async function canProveFreshFactor( - user: { id: string; totpConfirmedAt: Date | null }, - proofSource: FreshFactorProofSource, -): Promise { - if (await hasSecondFactorEnrolled(user)) return true; - if (proofSource === "second-factor") return false; - const passkeyCount = await prisma.passkey.count({ - where: { userId: user.id }, - }); - return passkeyCount > 0; -} - -/** - * A Bearer-resolved caller. - * - * `apiTokenId` and `accessTokenHash` are named rather than smuggled through - * `AuthContext.session.id`. That field means "the session row id" on the cookie - * path and "the ApiToken row id" on the Bearer path, and code that forgot which - * one it held passed a token id to a session-scoped query — deleting every one - * of the user's browser sessions and revoking the caller's own refresh token. - * Naming the fields is what stops that recurring. - */ -export interface BearerAuthContext extends AuthContext { - /** The `ApiToken` row id — the binding a step-up elevation is tied to. */ - apiTokenId: string; - /** - * HMAC of the presented access token, which is what `RefreshToken` - * cross-references in `accessTokenHash`. Lets a route identify the CALLER's - * own device login and spare it when revoking every other one. - */ - accessTokenHash: string; -} - -/** - * v1.30.34 — resolve a caller by Bearer token ONLY, refusing a cookie session. - * - * The mirror image of `requireCookieAuth`, and it exists for one surface: the - * step-up mint endpoints, which are the Bearer transport's own re-authentication - * flow and have no meaning for a browser (a browser re-proves a factor at login - * and carries the result on its session row). Refusing the cookie keeps the mint - * surface entirely outside the cookie's blast radius — no ambient credential can - * reach it, so the class of attack where a browser is induced to fire a request - * on the user's behalf simply does not apply. - * - * Scope handling is the standard fail-closed default: no declared scope, so only - * a cookie-equivalent (`["*"]`) token is admitted. A narrow token — an MCP grant, - * a medication-ingest grant — is refused 403 by the resolver. - */ -export async function requireBearerAuth(): Promise { - let authHeader: string | null = null; - try { - const headerList = await headers(); - authHeader = headerList.get("authorization"); - } catch { - authHeader = null; - } - if (!authHeader?.startsWith("Bearer ")) { - throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing); - } - const raw = authHeader.slice(7); - const auth = await authenticateBearer(raw, undefined); - await meterAccountWrite(auth.user.id); - return { - ...auth, - apiTokenId: auth.session.id, - accessTokenHash: hashToken(raw), - }; -} - -/** - * Header carrying a step-up elevation on the Bearer path. Never logged — the - * wide event captures no request headers at all, and `redactSecrets` carries an - * `hle_` rule as a second line of defence. - */ -export const STEP_UP_ELEVATION_HEADER = "x-step-up"; - -/** - * What an MFA-management route received, with the transport made explicit. - * - * The two arms carry DIFFERENT fields on purpose. A cookie caller has a session - * row and a Bearer caller does not, so `session` exists only on the cookie arm - * and the compiler refuses to read it on the other. This is the fix for a real - * defect: the MFA-disable route passed `session.id` to `destroyOtherSessions`, - * which on the Bearer path was an ApiToken id — matching no session row, so the - * "keep the current one" exclusion excluded nothing and the caller revoked its - * own device login. A comment would not have caught that; a type does. - */ -export type MfaManagementContext = { - user: SessionUser; - /** - * Spend the elevation. Call it immediately BEFORE the mutation, once every - * cheap validation has passed — a 429, a 422, or a wrong factor code must not - * burn a proof the user then has to mint again against a 5-per-15-minute - * ceiling. - * - * A no-op on the cookie arm (the session stamp is not consumable). On the - * Bearer arm it is the atomic single-use claim, and it THROWS - * `StepUpRequiredError` if the claim is lost — so a concurrent redemption - * still yields exactly one winner even though validation happened earlier. - */ - commitElevation: () => Promise; -} & ( - | { transport: "cookie"; session: { id: string; expiresAt: Date } } - | { transport: "bearer"; apiTokenId: string; accessTokenHash: string } -); - -/** - * v1.30.34 — the single gate for second-factor management. - * - * Every MFA-management MUTATION goes through here and nothing else does. The set - * is frozen by `src/__tests__/step-up-elevation-guard.test.ts`, so a future - * route cannot quietly join it: widening the reach of an elevation has to be a - * visible edit to that allowlist. - * - * Two accepted proofs, and they are equals rather than a primary and a fallback: - * - * COOKIE — unchanged, byte for byte. A cookie session delegates to - * `requireCookieAuth` / `requireFreshMfa` exactly as before this function - * existed. The web flow cannot regress here because there is no new code on - * its path; the elevation branch is only reached when there is no session at - * all. - * - * BEARER + ELEVATION — a token that resolves cleanly AND presents a valid, - * unconsumed elevation minted for that same token against a re-proved factor - * of sufficient strength. The token alone is never enough. - * - * FRESH FACTOR IS ABOUT WHICH FACTOR, NOT JUST HOW RECENT. On the cookie path - * `requireFreshMfa` reads `Session.mfaVerifiedAt`, and only a completed second - * factor or a primary passkey login ever writes it — a password login does not. - * The Bearer arm holds the identical line through `FRESH_FACTOR_METHODS`: a - * password-proved elevation reaches what a plain cookie session reaches and - * stops there. Without that rule, a stolen token plus the account password could - * rotate the recovery codes and spend one to disable the second factor. - * - * `requireAdmin` is untouched and stays cookie-only. An elevation cannot reach - * it — not because a check refuses one, but because `requireAdmin` resolves - * through `getSession()` and never consults this function or the header. - * - * @param options.freshFactor mirrors the cookie path's `requireFreshMfa`. Set by - * the destructive routes (disable, recovery-code rotation, security-key - * removal, passkey removal). - * @param options.proofSource which credentials count as "can prove a factor" - * when deciding whether the gate is reachable. Defaults to the second-factor - * set. Passkey removal passes `"any-possession"` because a primary passkey IS - * the proof there — see {@link FreshFactorProofSource}. It changes only the - * reachability pre-check; the gate itself is identical either way. - * @param options.freshFactorIfEnrolled the Bearer arm of ADDING a factor - * (TOTP setup, security-key registration). On an account that already has a - * second factor, the elevation must come from a second-factor or passkey - * proof, the rule `checkCookieEnrollmentProof` holds on the cookie arm; on - * one without, a password-proved elevation is still enough. The cookie arm - * is not changed by it: those routes run their own enrollment proof there. - */ -export async function requireMfaManagementAuth( - options: { - freshFactor?: boolean; - freshFactorIfEnrolled?: boolean; - proofSource?: FreshFactorProofSource; - } = {}, -): Promise { - const freshFactor = options.freshFactor === true; - const proofSource = options.proofSource ?? "second-factor"; - - // Cookie first, and via the original helpers — the web path runs the same - // code it always did. - const sessionData = await getSession(); - if (sessionData) { - const resolved = freshFactor - ? await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS, proofSource) - : await requireCookieAuth(); - return { - transport: "cookie", - user: resolved.user, - session: resolved.session, - commitElevation: async () => {}, - }; - } - - // Bearer path. Resolution first: an unknown, revoked, expired, or narrow-scope - // token is refused here and never gets as far as presenting an elevation. - const auth = await requireBearerAuth(); - const bearerFreshFactor = - freshFactor || - (options.freshFactorIfEnrolled === true && - (await hasSecondFactorEnrolled(auth.user))); - const commitElevation = await resolveBearerElevation( - { user: auth.user, apiTokenId: auth.apiTokenId }, - { freshFactor: bearerFreshFactor, proofSource }, - ); - - return { - transport: "bearer", - user: auth.user, - apiTokenId: auth.apiTokenId, - accessTokenHash: auth.accessTokenHash, - commitElevation, - }; -} - -/** - * The Bearer elevation arm, shared by `requireMfaManagementAuth` and - * `requireFreshMfaOrElevationIfEnrolled` so there is exactly one place that - * reads the header, validates, refuses, and claims. - * - * Takes an ALREADY-RESOLVED Bearer caller: the token's own validity and scope - * were decided before this runs, and nothing here can widen that decision. It - * returns the commit closure — the atomic single-use claim — and throws - * `StepUpRequiredError` for a missing, unknown, foreign, spent, expired, or - * too-weak elevation, with the real reason in the audit row and none of it on - * the wire. - */ -async function resolveBearerElevation( - auth: { user: SessionUser; apiTokenId: string }, - options: { freshFactor: boolean; proofSource: FreshFactorProofSource }, -): Promise<() => Promise> { - const { freshFactor, proofSource } = options; - - let raw: string | null = null; - try { - const headerList = await headers(); - raw = headerList.get(STEP_UP_ELEVATION_HEADER); - } catch { - raw = null; - } - - if (!raw) { - annotate({ - action: { name: "auth.stepup.elevation.missing" }, - meta: { maxAgeSeconds: STEP_UP_ELEVATION_TTL_SECONDS, freshFactor }, - }); - throw new StepUpRequiredError(); - } - const rawToken = raw; - - const refusal = (reason: string): StepUpRequiredError => { - // One audit row with the machine reason, one generic refusal on the wire. - // A prober learns only "not accepted" — never whether the elevation was - // unknown, already spent, expired, minted for a different token, or minted - // from a factor too weak for this route. - auditLog("auth.stepup.elevation.rejected", { - userId: auth.user.id, - details: { reason, freshFactor }, - }).catch(() => {}); - annotate({ - action: { name: "auth.stepup.elevation.rejected" }, - meta: { reason, freshFactor }, - }); - // Returned rather than thrown so every call site reads `throw refusal(...)` - // and the compiler narrows the result union afterwards. - return new StepUpRequiredError(); - }; - - // Validate WITHOUT consuming. The route runs its own cheap checks next and - // spends the elevation only when it is about to act. - const validated = await validateStepUpElevation({ - rawToken, - userId: auth.user.id, - apiTokenId: auth.apiTokenId, - requireFreshFactor: freshFactor, - }); - if (!validated.ok) throw refusal(validated.reason); - - // Parity with the cookie path: `requireFreshMfa` refuses an account that holds - // no credential able to produce the proof, because a step-up-gated action is - // unreachable rather than merely refused there. The Bearer path asks the same - // question, with the same `proofSource`, rather than becoming the softer or - // the stricter route. - if (freshFactor && !(await canProveFreshFactor(auth.user, proofSource))) { - throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled"); - } - - return async () => { - const claimed = await claimStepUpElevation({ - rawToken, - userId: auth.user.id, - apiTokenId: auth.apiTokenId, - requireFreshFactor: freshFactor, - }); - if (!claimed.ok) throw refusal(claimed.reason); - annotate({ - action: { name: "auth.stepup.elevation.accepted" }, - meta: { method: claimed.method, freshFactor }, - }); - }; -} - /** * Report unhandled errors to GlitchTip (fire-and-forget). * Uses dynamic import to avoid circular dependencies and startup cost. diff --git a/src/lib/auth/acting-carrier.ts b/src/lib/auth/acting-carrier.ts index 851ab7193..95d57daa7 100644 --- a/src/lib/auth/acting-carrier.ts +++ b/src/lib/auth/acting-carrier.ts @@ -4,7 +4,7 @@ * Two things in the app have to answer that question, and they have to answer * it the same way: * - * * `requireRecordAuth` (`src/lib/api-handler.ts`), which turns the claim + * * `requireRecordAuth` (`src/lib/auth/guards/record.ts`), which turns the claim * into a data scope after checking a live grant. * * `withIdempotency` (`src/lib/idempotency.ts`), which files the replay cell * under the record the request claims — before any handler runs, so it diff --git a/src/lib/auth/guards/acting.ts b/src/lib/auth/guards/acting.ts new file mode 100644 index 000000000..891d32bc1 --- /dev/null +++ b/src/lib/auth/guards/acting.ts @@ -0,0 +1,122 @@ +import { annotate, getEvent } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + carrierTarget, + decideActingCarrier, + readSelectorHeader, + type ActingCarrier, +} from "@/lib/auth/acting-carrier"; +import { SharingNotPermittedError } from "@/lib/api-errors"; + +import type { AuthContext } from "./context"; + +// ── The acting-account resolver ───────────────────────────────────────────── +// +// One function decides, for every transport, whether this request acts on +// somebody else's record — the same argument that put `resolveBearerToken` in +// one file for two wires. Two carriers converge on one decision because they +// are two answers to one question, and two answers that live apart drift. +// +// That decision lives in `@/lib/auth/acting-carrier`, because a second caller +// needs it: the idempotency wrapper files its replay cell under the record a +// request claims, and it runs before any auth context exists. What stays in +// the guards is what only they may do: turning a claim into a data scope after +// a live grant says so (`./record.ts`), and refusing one (below). + +/** + * The acting-account carrier for this request. + * + * The session value rides out of `getSession()` on the row that call already + * loaded — a second query for a column we have just read would be waste on + * every cookie request in the app. What does NOT ride out of `getSession()` is + * a substituted user: it keeps answering "who is calling" and nothing else, + * which is exactly why `requireAdmin`, `requireCookieAuth` and + * `requireFreshMfa` are unreachable by a switch. The carrier is an id to be + * checked, not a decision that has been made. + * + * Fresh on every request, by construction: `getSession()` reads the row from + * Postgres each time, so a switch cleared by a revocation is gone from the very + * next request rather than from the next login. + */ +export async function readActingCarrier( + auth: AuthContext, +): Promise { + return decideActingCarrier({ + transport: auth.authMethod === "bearer" ? "bearer" : "cookie", + stamped: auth.session.actingAsUserId ?? null, + header: await readSelectorHeader(), + }); +} + +/** + * Record a refused delegation. + * + * The row is filed under the CALLER, with no actor, and it is the operator's + * trail rather than the owner's. It names who called, the account they claimed, + * and why the claim was refused — none of which reaches the response. + * + * It is deliberately not filed under the claimed account, and that decision is + * worth stating because the opposite reads as the obvious one. A row is only + * written for a HEADER refusal, and a header is an unverified per-request + * assertion: the value is whatever the caller typed. Filing under it would let + * any authenticated caller write rows into any account's activity feed by + * naming it, which is a spam vector aimed at exactly the panel that exists to + * be trustworthy. Verifying the claim first would mean a grant lookup on a path + * whose whole job is to refuse without touching the record. + * + * What the owner does see is the other half: `recordDelegatedAccess` files a + * per-day row under the OWNER, naming the actor, for every delegated read that + * succeeded. "Who reached my record" is answered there. "Whose client sent a + * malformed claim" is an operator question and stays here. + * + * Fire-and-forget: an audit write that fails must not convert a 403 into a 500, + * because the 403 is the part that protects the record. + */ +export function auditRefusal( + auth: AuthContext, + carrier: ActingCarrier, + reason: string, +): void { + auditLog("sharing.access.denied", { + userId: auth.user.id, + details: { + reason, + carrier: carrier.kind, + target: carrierTarget(carrier), + method: getEvent()?.getHttpMethod() ?? null, + }, + }).catch(() => {}); +} + +/** + * This request says it is acting on another account and this route will not. + * + * Three ways to arrive: a route that never declared a mode (`undeclared_mode`), + * a selector sent over the transport that does not carry one + * (`misplaced_selector`), and a narrow single-purpose Bearer naming a record + * that is not its owner's (`narrow_scope_selector`). One response every way — + * the caller learns that the request will not be served, and nothing about + * grants. + * + * The audit rule, stated once because it is a judgement call: a HEADER refusal + * always writes a row, a SESSION refusal never does. The header is a per-request + * assertion by a client — a bug or a probe, and both are worth a durable record. + * The session field is ambient browser state: while a switch is on, every + * navigation, prefetch and poll that touches a non-delegable route lands here, + * and a row per such request would bury the header rows that matter under noise + * from the feature working as designed. Both are on the wide event either way. + */ +export function refuseUndeclaredMode( + auth: AuthContext, + carrier: ActingCarrier, + reason: + | "undeclared_mode" + | "misplaced_selector" + | "narrow_scope_selector" = "undeclared_mode", +): SharingNotPermittedError { + annotate({ meta: { sharing_refusal: reason } }); + if (carrier.kind !== "session") { + auditRefusal(auth, carrier, reason); + } + return new SharingNotPermittedError(); +} diff --git a/src/lib/auth/guards/caller.ts b/src/lib/auth/guards/caller.ts new file mode 100644 index 000000000..131bc9dda --- /dev/null +++ b/src/lib/auth/guards/caller.ts @@ -0,0 +1,297 @@ +import { headers } from "next/headers"; + +import { getEvent } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + resolveBearerToken, + BearerAuthError, + type ScopeRequirement, +} from "@/lib/auth/bearer"; +import { hashToken } from "@/lib/auth/hmac"; +import { + meterAccountWrite, + type WriteMeterOptions, +} from "@/lib/auth/write-meter"; +import { getSession } from "@/lib/auth/session"; +import { AUTH_ERROR_CODES, HttpError } from "@/lib/api-errors"; + +import { readActingCarrier, refuseUndeclaredMode } from "./acting"; +import { + READ_HTTP_METHODS, + isMcpAudienceToken, + type AuthContext, +} from "./context"; + +/** + * Require an authenticated request. Throws HttpError(401) / HttpError(403) on failure. + * + * Auth precedence (cookie-first, never both): + * 1. Valid session cookie → cookie path (existing behaviour, requiredPermission ignored). + * 2. No cookie + `Authorization: Bearer hlk_<...>` → API token path. + * 3. Neither → 401. + * + * And it REFUSES to run while the request is acting on another + * account. A bare `requireAuth()` is an undeclared route: nobody has decided + * whether it is safe under a switch, and the routes that call it include the + * password change, the MFA management surface, the token mint, and the + * integration connect. So a request carrying an acting-account carrier gets a + * 403 `sharing.not_permitted` here, and never a silent fall-back to the + * caller's own record. + * + * The fall-back is the tempting wrong answer and is worth naming: it looks + * harmless, it keeps every un-migrated route working, and it is the reverse + * data-mixing failure. A delegate reading what they believe is the owner's + * record would be shown their own; a delegate writing what they believe is the + * owner's measurement would write it into theirs. An inaccessible route is an + * inconvenience. A route that answers with the wrong person's health record is + * the product being wrong about the only thing it does. + * + * @param requiredPermission Optional permission scope. Only enforced for Bearer + * auth — cookie sessions always pass (full user access). Omitting it is a + * positive declaration: the route accepts cookie sessions and cookie- + * equivalent (`["*"]`) tokens ONLY, and refuses a narrow-scope token with + * HttpError(403). Naming a scope additionally admits narrow tokens that list + * it. A token that lists neither `*` nor the named scope throws + * HttpError(403). + */ +export async function requireAuth( + requiredPermission?: string, + options?: WriteMeterOptions, +): Promise { + const auth = await authenticateCaller(requiredPermission); + const carrier = await readActingCarrier(auth); + if (carrier.kind !== "none") { + throw refuseUndeclaredMode(auth, carrier); + } + // The shared write ceiling, for every write this resolver admits — see + // `meterAccountWrite`. + await meterAccountWrite(auth.user.id, options); + return auth; +} + +/** + * Resolve the caller as themselves. The pre-v1.36.0 `requireAuth` body, + * unchanged, split out so the three declared modes above it can share it. + * + * This function knows nothing about acting accounts and must not learn: it is + * the thing every mode agrees on before they disagree about what the request + * is allowed to reach. + */ +export async function authenticateCaller( + requiredPermission?: string, +): Promise { + // 1. Session cookie path — unchanged. + const sessionData = await getSession(); + if (sessionData) { + const evt = getEvent(); + if (evt) { + evt.setAuth({ + user_id: sessionData.user.id, + user_role: sessionData.user.role, + auth_method: "session", + }); + } + return { ...sessionData, authMethod: "cookie" }; + } + + // 2. Bearer-token path. + // `headers()` is only valid inside a Next.js request scope. Outside one + // (e.g. during direct unit tests of legacy routes that pre-date Bearer auth) + // we treat the absence of a header context as "no Bearer present" and fall + // through to the unauthenticated case below. + let authHeader: string | null = null; + try { + const headerList = await headers(); + authHeader = headerList.get("authorization"); + } catch { + authHeader = null; + } + if (authHeader?.startsWith("Bearer ")) { + return await authenticateBearer(authHeader.slice(7), requiredPermission); + } + + // 3. No credentials. + throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing); +} + +/** + * Authenticate a raw Bearer token against `ApiToken`. + * + * The validation itself lives in the transport-agnostic `resolveBearerToken` + * (`./auth/bearer`) — the single source of truth shared with the MCP wire. This + * wrapper adds the HTTP-edge concerns: the `auth.bearer.failure` audit trail + * (only the failure path writes a durable audit row — the success row was + * intentionally dropped for perf) and the Wide-Event `auth_method: "bearer"` + * annotation, then maps the result onto the `AuthContext` contract + * (`session.id` carries the token id). + * + * Authorisation contract (fail-closed): a route that declares no + * `requiredPermission` accepts cookie sessions and cookie-equivalent (`["*"]`) + * tokens only — a narrow-scope token is refused 403 with an `undeclared_scope` + * audit row. A route that declares a scope additionally accepts narrow tokens + * that list it. The absence of an argument is a positive statement, not an + * omission, which is what makes the default safe for routes nobody has thought + * about yet. + */ +export async function authenticateBearer( + rawToken: string, + requiredPermission: string | undefined, +): Promise { + const requirement: ScopeRequirement = requiredPermission + ? { kind: "scope", scope: requiredPermission } + : { kind: "wildcard-only" }; + let resolution; + try { + resolution = await resolveBearerToken(rawToken, requirement); + } catch (err) { + if (err instanceof BearerAuthError) { + auditLog("auth.bearer.failure", { + userId: err.userId ?? null, + details: { + reason: err.reason, + ...(err.tokenId ? { tokenId: err.tokenId } : {}), + ...(err.reason === "insufficient_permissions" + ? { required: requiredPermission } + : {}), + }, + }).catch(() => {}); + // Sentence and code are decided together, so a future rewording of the + // prose cannot silently change what a client does with the refusal. + const [message, errorCode] = + err.statusCode === 403 + ? (["Insufficient permissions", AUTH_ERROR_CODES.scope] as const) + : err.reason === "expired" + ? (["Token expired", AUTH_ERROR_CODES.expired] as const) + : (["Invalid token", AUTH_ERROR_CODES.invalid] as const); + throw new HttpError(err.statusCode, message, errorCode); + } + throw err; + } + + const { user, tokenId, expiresAt, permissions } = resolution; + + // H1 — audience binding at the resource server. An MCP-audience token + // (`health:read`, or `health:read health:write`) is bound to the `/mcp` + // surface; it may reach `/mcp` (a separate resolver that never runs this + // edge) and safe REST reads, but a write/delete over REST is outside its + // audience and is refused — INCLUDING a write-scoped token, whose writes are + // confined to the in-process `/mcp` tools and never granted over REST. Fail + // closed when the method is unknown + // (no event context) since every real REST request runs inside apiHandler, + // which always sets the method — an unknown method means we cannot prove a + // read, so we deny. This is RFC 8707 audience binding on the credential the + // client actually holds, not only during the OAuth exchange. + // + // Since the fail-closed scope default landed, this guard is unreachable on + // the deny path: an MCP-audience token carries no `*`, so `wildcard-only` + // already refused it in `resolveBearerToken` before we get here (an MCP + // token's REST reach is now nil, not "safe methods"). It is kept as defence + // in depth — it still holds the line if a future REST route ever declares + // `health:read` and so admits the token past the resolver. + if (isMcpAudienceToken(permissions)) { + const method = (getEvent()?.getHttpMethod() ?? "").toUpperCase(); + if (!READ_HTTP_METHODS.has(method)) { + auditLog("auth.bearer.failure", { + userId: user.id, + details: { + reason: "mcp_audience_write_blocked", + tokenId, + method: method || "unknown", + }, + }).catch(() => {}); + throw new HttpError( + 403, + "Insufficient permissions", + AUTH_ERROR_CODES.scope, + ); + } + } + + // No per-request success audit row. The polling iOS client drove a + // constant INSERT + pool checkout on every authenticated Bearer request; the + // wide event below already records `auth_method: "bearer"` + `user_id`, so the + // success path stays fully observable without the write churn. The failure + // path keeps its audit row. + const evt = getEvent(); + if (evt) { + evt.setAuth({ + user_id: user.id, + user_role: user.role, + auth_method: "bearer", + }); + } + + return { + session: { id: tokenId, expiresAt }, + user, + authMethod: "bearer", + // Non-null exactly when a NAMED scope admitted a narrow token. A `["*"]` + // token is cookie-equivalent and reads null however the route was declared; + // a narrow token on a route that named no scope never reaches this line, + // because `wildcard-only` already refused it in `resolveBearerToken`. + // + // Derived here rather than in the resolver so `bearer.ts` keeps its single + // authorisation arm: this is a report of the decision that already ran, not + // a second one. + bearerScope: permissions.includes("*") + ? null + : (requiredPermission ?? null), + }; +} + +/** + * A Bearer-resolved caller. + * + * `apiTokenId` and `accessTokenHash` are named rather than smuggled through + * `AuthContext.session.id`. That field means "the session row id" on the cookie + * path and "the ApiToken row id" on the Bearer path, and code that forgot which + * one it held passed a token id to a session-scoped query — deleting every one + * of the user's browser sessions and revoking the caller's own refresh token. + * Naming the fields is what stops that recurring. + */ +export interface BearerAuthContext extends AuthContext { + /** The `ApiToken` row id — the binding a step-up elevation is tied to. */ + apiTokenId: string; + /** + * HMAC of the presented access token, which is what `RefreshToken` + * cross-references in `accessTokenHash`. Lets a route identify the CALLER's + * own device login and spare it when revoking every other one. + */ + accessTokenHash: string; +} + +/** + * Resolve a caller by Bearer token ONLY, refusing a cookie session. + * + * The mirror image of `requireCookieAuth`, and it exists for one surface: the + * step-up mint endpoints, which are the Bearer transport's own re-authentication + * flow and have no meaning for a browser (a browser re-proves a factor at login + * and carries the result on its session row). Refusing the cookie keeps the mint + * surface entirely outside the cookie's blast radius — no ambient credential can + * reach it, so the class of attack where a browser is induced to fire a request + * on the user's behalf simply does not apply. + * + * Scope handling is the standard fail-closed default: no declared scope, so only + * a cookie-equivalent (`["*"]`) token is admitted. A narrow token — an MCP grant, + * a medication-ingest grant — is refused 403 by the resolver. + */ +export async function requireBearerAuth(): Promise { + let authHeader: string | null = null; + try { + const headerList = await headers(); + authHeader = headerList.get("authorization"); + } catch { + authHeader = null; + } + if (!authHeader?.startsWith("Bearer ")) { + throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing); + } + const raw = authHeader.slice(7); + const auth = await authenticateBearer(raw, undefined); + await meterAccountWrite(auth.user.id); + return { + ...auth, + apiTokenId: auth.session.id, + accessTokenHash: hashToken(raw), + }; +} diff --git a/src/lib/auth/guards/context.ts b/src/lib/auth/guards/context.ts new file mode 100644 index 000000000..8465c6039 --- /dev/null +++ b/src/lib/auth/guards/context.ts @@ -0,0 +1,111 @@ +/** + * The authenticated request context every guard returns, and the two + * predicates over a credential that more than one guard needs. + * + * The guards themselves are in the sibling modules; route handlers import + * all of them through `@/lib/api-handler`. + */ +import type { SessionUser } from "@/lib/auth/session-user"; +import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "@/lib/mcp/oauth/config"; + +/** + * HTTP methods a read-only credential may use on the REST surface. A request + * with any other method (POST / PUT / PATCH / DELETE) is a write. + * + * The MCP-audience guard below assumes these methods are side-effect-free: + * an MCP-bound token is admitted on them. A future side-effecting GET (or HEAD) + * would silently widen that token's reach over REST — do NOT add one without + * revisiting the MCP audience binding here. + */ +export const READ_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + +/** + * Whether a token is MCP-audience-bound (H1). The MCP OAuth bridge and the + * connector settings card mint tokens whose ONLY grants are `health:read` — + * and, when the user consents to logging, `health:read health:write`. Either + * shape is bound to the MCP surface: the `/mcp` resolver accepts it and so do + * safe (read) REST methods, but it must NEVER reach a REST write/delete. The + * `health:write` grant admits writes ONLY in-process over `/mcp` (the confirmed + * write tools), never over REST — so a write-scoped MCP token is exactly as + * audience-bound on this edge as a read-only one. A token carrying any broader + * or legacy grant (`*`, `medication:ingest`, …) is NOT MCP-audience-bound and + * keeps its existing reach. + */ +export function isMcpAudienceToken(permissions: readonly string[]): boolean { + if (permissions.length === 0) return false; + return permissions.every( + (p) => p === SCOPE_HEALTH_READ || p === SCOPE_HEALTH_WRITE, + ); +} + +/** + * Authenticated request context. Returned by both session-cookie and Bearer-token + * authentication paths. The `session.id` is the session-record id for cookie auth + * and the `ApiToken` id for bearer auth — callers must not assume the id refers + * to a Session row. + */ +export type AuthContext = { + session: { + id: string; + expiresAt: Date; + /** + * The cookie transport's acting-account carrier, straight off + * the session row. Absent on the Bearer path, which has no session row and + * carries its selector in a per-request header instead. + * + * Optional so that the ~180 test files which hand `requireAuth` a + * hand-built session context keep compiling; an absent field means the + * same thing as a null one — no switch. Only the acting-account resolver + * below reads it. + */ + readonly actingAsUserId?: string | null; + /** + * How many times this session's record selector has moved. The + * record-session fence compares it against what the request asserted. + * + * Optional for the same reason as `actingAsUserId` above, and `undefined` + * reads as `0` — which is the fence's exemption, so a hand-built test + * context keeps meaning "a session that never switched". Read only by the + * fence. + */ + readonly recordEpoch?: number; + }; + user: SessionUser; + /** + * Transport the caller authenticated with, derived from the SAME branch that + * sets the wide event's `auth_method`: the cookie-session path resolves + * `"cookie"`, the Bearer-token path resolves `"bearer"`. Read-only and never + * client-asserted — it is the transport fact, not a request field. A route + * that must record write provenance (e.g. the medication-intake `source`) + * maps it server-side, the same posture as `userId` being narrowed from auth. + */ + readonly authMethod: "cookie" | "bearer"; + /** + * The named scope that admitted this Bearer, or null when the credential is + * cookie-equivalent — a session, or a `["*"]` token. A non-null value marks a + * NARROW, single-purpose credential: one minted for one job, by its own + * holder, from their own settings page. + * + * A single nullable string rather than the token's permission array, and that + * is the point: nothing downstream can re-derive an authorisation decision + * from it. The decision was made once, in `resolveBearerToken`; this only + * reports which arm ran, so a route can refuse a narrow credential something + * a cookie session may still do. + * + * Optional for the reason `actingAsUserId` and `recordEpoch` above are: the + * test files that hand-build this object keep compiling, and an absent field + * reads as null — "cookie-equivalent", which is what a hand-built context has + * always meant. + */ + readonly bearerScope?: string | null; +}; + +/** + * Was this request authenticated by a narrow, single-purpose Bearer scope? + * + * The one reader of `bearerScope`, exported so no handler re-derives the test + * and they cannot drift on what "narrow" means. + */ +export function isScopedCredential(auth: AuthContext): boolean { + return (auth.bearerScope ?? null) !== null; +} diff --git a/src/lib/auth/guards/cookie.ts b/src/lib/auth/guards/cookie.ts new file mode 100644 index 000000000..d42caef97 --- /dev/null +++ b/src/lib/auth/guards/cookie.ts @@ -0,0 +1,65 @@ +import { getEvent } from "@/lib/logging/context"; +import { getSession } from "@/lib/auth/session"; +import { meterAccountWrite } from "@/lib/auth/write-meter"; +import { AUTH_ERROR_CODES, HttpError } from "@/lib/api-errors"; + +import type { AuthContext } from "./context"; + +/** + * Require an authenticated admin user. Throws HttpError(401) or HttpError(403). + * Cookie-only — Bearer tokens never elevate to admin (security boundary). + * Automatically annotates the Wide Event with auth context. + * + * Unreachable by a switch, structurally: it resolves through + * `getSession()`, which knows only who is calling, and the role is read off + * that row. Switching into an ADMIN's record therefore confers nothing, and + * making it confer something would take a deliberate edit here rather than an + * oversight elsewhere. + */ +export async function requireAdmin(): Promise { + const sessionData = await getSession(); + if (!sessionData) + throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing); + + const evt = getEvent(); + if (evt) { + evt.setAuth({ + user_id: sessionData.user.id, + user_role: sessionData.user.role, + auth_method: "session", + }); + } + + if (sessionData.user.role !== "ADMIN") { + throw new HttpError(403, "Admin access required", AUTH_ERROR_CODES.admin); + } + await meterAccountWrite(sessionData.user.id); + return { ...sessionData, authMethod: "cookie" }; +} + +/** + * Require a cookie-backed session, refusing Bearer tokens. + * + * Cookie-only by the same structural argument as `requireAdmin`: it resolves + * the session via `getSession()` (which reads only the session cookie) and + * never falls through to the Bearer branch. The second-factor management + * surfaces (TOTP enroll / confirm / disable / recovery-code regenerate) use + * this so an API token — even a wildcard one — can never enrol or tear down + * MFA on the account it belongs to. MFA management is a browser-only action. + */ +export async function requireCookieAuth(): Promise { + const sessionData = await getSession(); + if (!sessionData) + throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing); + + const evt = getEvent(); + if (evt) { + evt.setAuth({ + user_id: sessionData.user.id, + user_role: sessionData.user.role, + auth_method: "session", + }); + } + await meterAccountWrite(sessionData.user.id); + return { ...sessionData, authMethod: "cookie" }; +} diff --git a/src/lib/auth/guards/elevation.ts b/src/lib/auth/guards/elevation.ts new file mode 100644 index 000000000..40be478ce --- /dev/null +++ b/src/lib/auth/guards/elevation.ts @@ -0,0 +1,110 @@ +import { headers } from "next/headers"; + +import type { SessionUser } from "@/lib/auth/session-user"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + claimStepUpElevation, + validateStepUpElevation, + STEP_UP_ELEVATION_TTL_SECONDS, +} from "@/lib/auth/step-up"; +import { StepUpRequiredError } from "@/lib/api-errors"; + +import { + canProveFreshFactor, + type FreshFactorProofSource, +} from "./fresh-factor"; + +/** + * Header carrying a step-up elevation on the Bearer path. Never logged — the + * wide event captures no request headers at all, and `redactSecrets` carries an + * `hle_` rule as a second line of defence. + */ +export const STEP_UP_ELEVATION_HEADER = "x-step-up"; + +/** + * The Bearer elevation arm, shared by `requireMfaManagementAuth` and + * `requireFreshMfaOrElevationIfEnrolled` so there is exactly one place that + * reads the header, validates, refuses, and claims. + * + * Takes an ALREADY-RESOLVED Bearer caller: the token's own validity and scope + * were decided before this runs, and nothing here can widen that decision. It + * returns the commit closure — the atomic single-use claim — and throws + * `StepUpRequiredError` for a missing, unknown, foreign, spent, expired, or + * too-weak elevation, with the real reason in the audit row and none of it on + * the wire. + */ +export async function resolveBearerElevation( + auth: { user: SessionUser; apiTokenId: string }, + options: { freshFactor: boolean; proofSource: FreshFactorProofSource }, +): Promise<() => Promise> { + const { freshFactor, proofSource } = options; + + let raw: string | null = null; + try { + const headerList = await headers(); + raw = headerList.get(STEP_UP_ELEVATION_HEADER); + } catch { + raw = null; + } + + if (!raw) { + annotate({ + action: { name: "auth.stepup.elevation.missing" }, + meta: { maxAgeSeconds: STEP_UP_ELEVATION_TTL_SECONDS, freshFactor }, + }); + throw new StepUpRequiredError(); + } + const rawToken = raw; + + const refusal = (reason: string): StepUpRequiredError => { + // One audit row with the machine reason, one generic refusal on the wire. + // A prober learns only "not accepted" — never whether the elevation was + // unknown, already spent, expired, minted for a different token, or minted + // from a factor too weak for this route. + auditLog("auth.stepup.elevation.rejected", { + userId: auth.user.id, + details: { reason, freshFactor }, + }).catch(() => {}); + annotate({ + action: { name: "auth.stepup.elevation.rejected" }, + meta: { reason, freshFactor }, + }); + // Returned rather than thrown so every call site reads `throw refusal(...)` + // and the compiler narrows the result union afterwards. + return new StepUpRequiredError(); + }; + + // Validate WITHOUT consuming. The route runs its own cheap checks next and + // spends the elevation only when it is about to act. + const validated = await validateStepUpElevation({ + rawToken, + userId: auth.user.id, + apiTokenId: auth.apiTokenId, + requireFreshFactor: freshFactor, + }); + if (!validated.ok) throw refusal(validated.reason); + + // Parity with the cookie path: `requireFreshMfa` refuses an account that holds + // no credential able to produce the proof, because a step-up-gated action is + // unreachable rather than merely refused there. The Bearer path asks the same + // question, with the same `proofSource`, rather than becoming the softer or + // the stricter route. + if (freshFactor && !(await canProveFreshFactor(auth.user, proofSource))) { + throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled"); + } + + return async () => { + const claimed = await claimStepUpElevation({ + rawToken, + userId: auth.user.id, + apiTokenId: auth.apiTokenId, + requireFreshFactor: freshFactor, + }); + if (!claimed.ok) throw refusal(claimed.reason); + annotate({ + action: { name: "auth.stepup.elevation.accepted" }, + meta: { method: claimed.method, freshFactor }, + }); + }; +} diff --git a/src/lib/auth/guards/fresh-factor.ts b/src/lib/auth/guards/fresh-factor.ts new file mode 100644 index 000000000..ba6dd1ca1 --- /dev/null +++ b/src/lib/auth/guards/fresh-factor.ts @@ -0,0 +1,92 @@ +import { prisma } from "@/lib/db"; +import { hasSecondFactorEnrolled } from "@/lib/auth/second-factor"; + +/** + * Default step-up freshness window (5 minutes) for sensitive mutations. + * Within the 5–15 min band OWASP recommends; tight end because the gated + * actions (disable MFA, regenerate codes, and later key rotation / export) + * are destructive. + */ +export const MFA_STEP_UP_MAX_AGE_SECONDS = 5 * 60; + +/** + * Which credentials count when asking "can this account produce a fresh-factor + * proof at all". + * + * The gate is the same either way — a `Session.mfaVerifiedAt` inside the window + * on the cookie path, an elevation minted from a `FRESH_FACTOR_METHODS` proof on + * the Bearer path. What differs is the pre-check that decides whether refusing + * would be a gate or a dead end, and the honest answer to that depends on what + * the route is protecting. + * + * `second-factor` — a confirmed TOTP secret or a registered second-factor + * security key. The default of `requireMfaManagementAuth`, and the right + * question for the routes that MANAGE a second factor: there is nothing there + * to protect if nothing is enrolled. + * + * `any-possession` — the above, OR a registered PRIMARY passkey. The default + * of `requireFreshMfa` since v1.43.3, and the right question for a route + * protecting the passkey itself, because a passkey is a + * possession factor in its own right and this codebase already treats it as + * one in three places: `/api/auth/passkey/login-verify` stamps `mfaVerifiedAt` + * on a passkey login (v1.23, M-review M1), `passkey` is in + * `FRESH_FACTOR_METHODS` so a passkey-proved elevation reaches the fresh-factor + * routes, and `resolveMfaEnrollmentRequired` counts a primary passkey as + * satisfying the enforcement policy outright. A passkey-only account can + * therefore clear the gate on both transports, and asking the narrower + * question would have thrown `mfa_not_enrolled` at an account that was holding + * the proof all along. + */ +export type FreshFactorProofSource = "second-factor" | "any-possession"; + +/* + * A PASSKEY COUNTS AS A SECOND FACTOR FOR THE UNCONDITIONAL STEP-UP GATE. + * + * Maintainer decision, v1.43.3. `requireFreshMfa` defaults to + * `any-possession`, so an account whose only strong credential is a passkey + * is no longer refused `mfa_not_enrolled` on the routes that call it directly + * (creating, editing, deleting and handing over a managed profile, inviting + * and removing a guardian, the admin key rotation). It clears the gate the + * way every other account does: a passkey sign-in or a passkey re-proof at + * `POST /api/auth/reproof` stamps `Session.mfaVerifiedAt`, and the gate reads + * that stamp inside the same five minutes. + * + * The limits, each deliberate: + * + * - `hasSecondFactorEnrolled` keeps its narrow meaning. It decides whether + * the CONDITIONAL gates (`requireFreshMfaIfEnrolled`, + * `requireFreshMfaOrElevationIfEnrolled`, `requireRecentProof`'s branches, + * the enrollment proofs) apply at all. Widening it would newly gate + * account deletion, the data reset, the password change and the export + * for every passkey holder, take the password re-proof away from them, and + * change what the native app's Bearer requests need. None of that is part + * of the decision. + * - Password login is untouched. It asks for a second factor from + * `totpConfirmedAt` and the security-key count only; a passkey-only + * account signing in with its password is not challenged, as before. + * - The MFA-management routes (`requireMfaManagementAuth`) still default to + * `second-factor`: disabling TOTP or rotating recovery codes on an account + * with no second factor has nothing to protect. + * - Cookie only, as ever. The Bearer path is not reached from here. + */ + +/** + * Can this account produce a fresh-factor proof at all? + * + * The wider question, asked by the routes that gate on possession rather than + * on second-factor management. See {@link FreshFactorProofSource} for why a + * primary passkey answers yes and where the codebase already says so; see + * {@link hasSecondFactorEnrolled} for why the narrower predicate must not be + * widened into this one. + */ +export async function canProveFreshFactor( + user: { id: string; totpConfirmedAt: Date | null }, + proofSource: FreshFactorProofSource, +): Promise { + if (await hasSecondFactorEnrolled(user)) return true; + if (proofSource === "second-factor") return false; + const passkeyCount = await prisma.passkey.count({ + where: { userId: user.id }, + }); + return passkeyCount > 0; +} diff --git a/src/lib/auth/guards/fresh-mfa.ts b/src/lib/auth/guards/fresh-mfa.ts new file mode 100644 index 000000000..3945a1a69 --- /dev/null +++ b/src/lib/auth/guards/fresh-mfa.ts @@ -0,0 +1,196 @@ +import { prisma } from "@/lib/db"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { getSession } from "@/lib/auth/session"; +import { + hasSecondFactorEnrolled, + recentProofMethods, +} from "@/lib/auth/second-factor"; +import { meterAccountWrite } from "@/lib/auth/write-meter"; +import { + AUTH_ERROR_CODES, + HttpError, + StepUpRequiredError, +} from "@/lib/api-errors"; + +import { requireAuth } from "./caller"; +import type { AuthContext } from "./context"; +import { resolveBearerElevation } from "./elevation"; +import { + canProveFreshFactor, + type FreshFactorProofSource, +} from "./fresh-factor"; + +export type FreshMfaContext = AuthContext & { mfaVerifiedAt: Date }; + +/** + * Step-up gate. Passes only for a COOKIE session whose + * `Session.mfaVerifiedAt` is within `maxAgeSeconds` AND whose user holds a + * credential that can produce such a stamp. Throws `StepUpRequiredError` + * (401, `errorCode: "auth.stepup.required"`) otherwise. + * + * Bearer tokens can NEVER satisfy this — exactly like `requireAdmin`, the + * resolution path is `getSession()` (cookie-only) and there is no Bearer + * fall-through. A token transport carries no `mfaVerifiedAt` and cannot + * acquire one, so the boundary is structural, not a softenable runtime check. + * + * Consumed in Phase M by MFA disable + recovery-code regeneration; later + * waves gate account deletion, key rotation, and passphrase export on it. + * + * @param proofSource which credentials count as "can prove a factor". Defaults + * to `any-possession`, so a primary passkey counts (see the decision note + * below {@link FreshFactorProofSource}); `requireMfaManagementAuth` passes + * `second-factor` explicitly. + */ +export async function requireFreshMfa( + maxAgeSeconds: number, + proofSource: FreshFactorProofSource = "any-possession", +): Promise { + const sessionData = await getSession(); + if (!sessionData) + throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing); + + const evt = getEvent(); + if (evt) { + evt.setAuth({ + user_id: sessionData.user.id, + user_role: sessionData.user.role, + auth_method: "session", + }); + } + + await meterAccountWrite(sessionData.user.id); + + // The account must hold a credential that can stamp `mfaVerifiedAt` in the + // first place. Without one a step-up-gated action is unreachable by design + // rather than merely refused, so the refusal is named differently + // (`mfa_not_enrolled`) and the management UI gates enrolment ahead of it. + if (!(await canProveFreshFactor(sessionData.user, proofSource))) { + throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled"); + } + + // Read the freshness stamp off the live session row — `getSession`'s + // projection intentionally omits it. + const row = await prisma.session.findUnique({ + where: { id: sessionData.session.id }, + select: { mfaVerifiedAt: true }, + }); + const verifiedAt = row?.mfaVerifiedAt ?? null; + if (!verifiedAt || verifiedAt.getTime() < Date.now() - maxAgeSeconds * 1000) { + // Name the proofs that clear this gate, the way the recent-proof gate + // does, so the browser can open the re-proof dialog instead of printing a + // sentence with nowhere to act on it. Only the possession proofs: a + // `POST /api/auth/reproof` with one of them stamps `mfaVerifiedAt`, a + // password stamps `reproofAt` only and would leave this gate shut. + const methods = (await recentProofMethods(sessionData.user)).filter( + (method) => method !== "password", + ); + annotate({ meta: { step_up_methods: methods.join(",") } }); + throw new StepUpRequiredError( + "auth.stepup.required", + "Recent second-factor verification required", + { methods }, + ); + } + + return { ...sessionData, authMethod: "cookie", mfaVerifiedAt: verifiedAt }; +} + +/** + * Conditional step-up for destructive account actions. + * + * Resolves the caller with the standard `requireAuth()` (cookie OR Bearer). + * For an account WITHOUT a confirmed second factor the caller passes straight + * through — a single-factor user is intentionally unaffected, so account + * deletion / data reset keeps its existing typed-confirmation-only contract. + * For an account WITH MFA active (`totpConfirmedAt` set) it additionally runs + * `requireFreshMfa`, which is cookie-only by construction: an MFA-enrolled + * account's Bearer transport carries no `mfaVerifiedAt` and therefore cannot + * satisfy step-up, surfacing `StepUpRequiredError` (401, + * `errorCode: "auth.stepup.required"`) so the UI launches a re-verification. + * + * Gating only the MFA-enrolled cohort keeps the boundary structural: a + * hijacked live cookie session for an MFA user cannot nuke the record without + * a fresh factor, while users who never opted into MFA are not forced through + * a flow they have no way to complete. + */ +export async function requireFreshMfaIfEnrolled( + maxAgeSeconds: number, +): Promise { + const auth = await requireAuth(); + if (await hasSecondFactorEnrolled(auth.user)) { + await requireFreshMfa(maxAgeSeconds); + } + return auth; +} + +/** + * What an erasure route received: the caller, plus the hook that spends a + * Bearer elevation. See `MfaManagementContext.commitElevation` for the + * contract — call it immediately before the mutation, after every cheap + * refusal (a bad confirmation, the last-admin check) has passed, so a 422 + * does not burn a proof the user then has to mint again. + */ +export type ErasureAuthContext = AuthContext & { + commitElevation: () => Promise; +}; + +/** + * The sibling of `requireFreshMfaIfEnrolled` for the two erasure routes — + * account deletion and the record wipe — and only those. + * + * WHY IT EXISTS. App Store guideline 5.1.1(v) requires that an account created + * in the app can be deleted from the app. `requireFreshMfaIfEnrolled` gates an + * MFA-enrolled account on `requireFreshMfa`, which is cookie-only by + * construction, so a native-app user with a second factor could not delete + * their account from the app at all: the Bearer transport had no way to + * present a fresh factor and the route answered 401 "Not authenticated" to a + * perfectly valid token. This helper adds the Bearer arm without touching + * `requireFreshMfa`, which stays cookie-only. + * + * THE THREE ARMS. + * + * No second factor enrolled — pass through on either transport, exactly as + * the sibling does. A user who never enrolled has no ceremony to complete. + * + * Cookie + enrolled — `requireFreshMfa`, unchanged: `Session.mfaVerifiedAt` + * within the window. A browser session cannot tell the two helpers apart. + * + * Bearer + enrolled — a step-up elevation in the `X-Step-Up` header, minted + * by `POST /api/auth/step-up` against a re-proved factor, validated by the + * same code the MFA-management routes use and with `freshFactor` pinned ON: + * a `totp`, `webauthn`, or `passkey` proof passes; a password proof is + * refused with the same generic 401, because a password login has never + * satisfied step-up on the web and must not start here. The elevation is + * token-bound, single-use, and five minutes wide — the cookie window. + * + * WHAT IT DOES NOT DO. Scope is `requireAuth()`'s fail-closed default, so a + * narrow token is refused 403 before the header is read. A recovery code is + * not accepted on Bearer — the mint does not take one, and `FRESH_FACTOR_METHODS` + * says why; an account that has lost its authenticator erases itself on the + * web, where `/api/auth/mfa/verify` accepts the code and stamps the session. + * And the gate takes no factor in the BODY: there is one mint surface and one + * redemption path, which is what `step-up-elevation-guard.test.ts` freezes. + */ +export async function requireFreshMfaOrElevationIfEnrolled( + maxAgeSeconds: number, +): Promise { + const auth = await requireAuth(); + const noop = async () => {}; + + if (!(await hasSecondFactorEnrolled(auth.user))) { + return { ...auth, commitElevation: noop }; + } + + if (auth.authMethod === "cookie") { + await requireFreshMfa(maxAgeSeconds); + return { ...auth, commitElevation: noop }; + } + + // On the Bearer path `requireAuth` puts the resolved `ApiToken` row id in + // `session.id` — the binding the elevation was minted against. + const commitElevation = await resolveBearerElevation( + { user: auth.user, apiTokenId: auth.session.id }, + { freshFactor: true, proofSource: "second-factor" }, + ); + return { ...auth, commitElevation }; +} diff --git a/src/lib/auth/guards/mfa-management.ts b/src/lib/auth/guards/mfa-management.ts new file mode 100644 index 000000000..8f7fd1908 --- /dev/null +++ b/src/lib/auth/guards/mfa-management.ts @@ -0,0 +1,135 @@ +import type { SessionUser } from "@/lib/auth/session-user"; +import { getSession } from "@/lib/auth/session"; +import { hasSecondFactorEnrolled } from "@/lib/auth/second-factor"; + +import { requireBearerAuth } from "./caller"; +import { requireCookieAuth } from "./cookie"; +import { resolveBearerElevation } from "./elevation"; +import { + MFA_STEP_UP_MAX_AGE_SECONDS, + type FreshFactorProofSource, +} from "./fresh-factor"; +import { requireFreshMfa } from "./fresh-mfa"; + +/** + * What an MFA-management route received, with the transport made explicit. + * + * The two arms carry DIFFERENT fields on purpose. A cookie caller has a session + * row and a Bearer caller does not, so `session` exists only on the cookie arm + * and the compiler refuses to read it on the other. This is the fix for a real + * defect: the MFA-disable route passed `session.id` to `destroyOtherSessions`, + * which on the Bearer path was an ApiToken id — matching no session row, so the + * "keep the current one" exclusion excluded nothing and the caller revoked its + * own device login. A comment would not have caught that; a type does. + */ +export type MfaManagementContext = { + user: SessionUser; + /** + * Spend the elevation. Call it immediately BEFORE the mutation, once every + * cheap validation has passed — a 429, a 422, or a wrong factor code must not + * burn a proof the user then has to mint again against a 5-per-15-minute + * ceiling. + * + * A no-op on the cookie arm (the session stamp is not consumable). On the + * Bearer arm it is the atomic single-use claim, and it THROWS + * `StepUpRequiredError` if the claim is lost — so a concurrent redemption + * still yields exactly one winner even though validation happened earlier. + */ + commitElevation: () => Promise; +} & ( + | { transport: "cookie"; session: { id: string; expiresAt: Date } } + | { transport: "bearer"; apiTokenId: string; accessTokenHash: string } +); + +/** + * The single gate for second-factor management. + * + * Every MFA-management MUTATION goes through here and nothing else does. The set + * is frozen by `src/__tests__/step-up-elevation-guard.test.ts`, so a future + * route cannot quietly join it: widening the reach of an elevation has to be a + * visible edit to that allowlist. + * + * Two accepted proofs, and they are equals rather than a primary and a fallback: + * + * COOKIE — unchanged, byte for byte. A cookie session delegates to + * `requireCookieAuth` / `requireFreshMfa` exactly as before this function + * existed. The web flow cannot regress here because there is no new code on + * its path; the elevation branch is only reached when there is no session at + * all. + * + * BEARER + ELEVATION — a token that resolves cleanly AND presents a valid, + * unconsumed elevation minted for that same token against a re-proved factor + * of sufficient strength. The token alone is never enough. + * + * FRESH FACTOR IS ABOUT WHICH FACTOR, NOT JUST HOW RECENT. On the cookie path + * `requireFreshMfa` reads `Session.mfaVerifiedAt`, and only a completed second + * factor or a primary passkey login ever writes it — a password login does not. + * The Bearer arm holds the identical line through `FRESH_FACTOR_METHODS`: a + * password-proved elevation reaches what a plain cookie session reaches and + * stops there. Without that rule, a stolen token plus the account password could + * rotate the recovery codes and spend one to disable the second factor. + * + * `requireAdmin` is untouched and stays cookie-only. An elevation cannot reach + * it — not because a check refuses one, but because `requireAdmin` resolves + * through `getSession()` and never consults this function or the header. + * + * @param options.freshFactor mirrors the cookie path's `requireFreshMfa`. Set by + * the destructive routes (disable, recovery-code rotation, security-key + * removal, passkey removal). + * @param options.proofSource which credentials count as "can prove a factor" + * when deciding whether the gate is reachable. Defaults to the second-factor + * set. Passkey removal passes `"any-possession"` because a primary passkey IS + * the proof there — see {@link FreshFactorProofSource}. It changes only the + * reachability pre-check; the gate itself is identical either way. + * @param options.freshFactorIfEnrolled the Bearer arm of ADDING a factor + * (TOTP setup, security-key registration). On an account that already has a + * second factor, the elevation must come from a second-factor or passkey + * proof, the rule `checkCookieEnrollmentProof` holds on the cookie arm; on + * one without, a password-proved elevation is still enough. The cookie arm + * is not changed by it: those routes run their own enrollment proof there. + */ +export async function requireMfaManagementAuth( + options: { + freshFactor?: boolean; + freshFactorIfEnrolled?: boolean; + proofSource?: FreshFactorProofSource; + } = {}, +): Promise { + const freshFactor = options.freshFactor === true; + const proofSource = options.proofSource ?? "second-factor"; + + // Cookie first, and via the original helpers — the web path runs the same + // code it always did. + const sessionData = await getSession(); + if (sessionData) { + const resolved = freshFactor + ? await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS, proofSource) + : await requireCookieAuth(); + return { + transport: "cookie", + user: resolved.user, + session: resolved.session, + commitElevation: async () => {}, + }; + } + + // Bearer path. Resolution first: an unknown, revoked, expired, or narrow-scope + // token is refused here and never gets as far as presenting an elevation. + const auth = await requireBearerAuth(); + const bearerFreshFactor = + freshFactor || + (options.freshFactorIfEnrolled === true && + (await hasSecondFactorEnrolled(auth.user))); + const commitElevation = await resolveBearerElevation( + { user: auth.user, apiTokenId: auth.apiTokenId }, + { freshFactor: bearerFreshFactor, proofSource }, + ); + + return { + transport: "bearer", + user: auth.user, + apiTokenId: auth.apiTokenId, + accessTokenHash: auth.accessTokenHash, + commitElevation, + }; +} diff --git a/src/lib/auth/guards/recent-proof.ts b/src/lib/auth/guards/recent-proof.ts new file mode 100644 index 000000000..df7cf1fa4 --- /dev/null +++ b/src/lib/auth/guards/recent-proof.ts @@ -0,0 +1,126 @@ +import { prisma } from "@/lib/db"; +import type { SessionUser } from "@/lib/auth/session-user"; +import { annotate } from "@/lib/logging/context"; +import { + hasSecondFactorEnrolled, + recentProofMethods, +} from "@/lib/auth/second-factor"; +import { REPROOF_REQUIRED_CODE, StepUpRequiredError } from "@/lib/api-errors"; + +import { requireAuth } from "./caller"; +import { resolveBearerElevation } from "./elevation"; +import { MFA_STEP_UP_MAX_AGE_SECONDS } from "./fresh-factor"; +import type { ErasureAuthContext } from "./fresh-mfa"; + +/** + * How the recent-proof gate treats a Bearer caller. + * + * `elevation` — the Bearer twin of the cookie rule: an `X-Step-Up` elevation + * minted by `POST /api/auth/step-up`. On an account with a second factor it + * must come from a second-factor proof; without one, a password-proved + * elevation is enough. For routes no shipped client calls on Bearer. + * + * `elevation-if-enrolled` — the erasure routes' rule + * (`requireFreshMfaOrElevationIfEnrolled`): an account with a second factor + * presents a second-factor elevation, one without passes on the token. For a + * route the shipped app calls, where the second-factor arm was already + * refused before and the other arm must keep working. + * + * `token` — the token is the proof, as before. Only for routes the shipped + * app calls without an elevation, where a refusal would sign the app out (it + * reads a 401 outside its known paths as a dead session). Each one is named + * in the iOS note and in `recent-proof-surface-guard.test.ts`, so tightening + * it later is a visible edit. + */ +export type RecentProofBearerRule = + "elevation" | "elevation-if-enrolled" | "token"; + +/** + * The fresh-proof gate in front of the actions that hand the record, or a + * standing credential to it, to somebody: exporting the whole record, making a + * clinician share link, minting a token, and the admin backup, restore, wipe + * and reset actions. A live session is not enough for those, because whoever + * holds a stolen session would otherwise walk off with the record or leave a + * credential behind that outlives the session. + * + * Cookie session, account WITH a second factor: `Session.mfaVerifiedAt` + * within five minutes — exactly `requireFreshMfa`. Signing in completes the + * factor; later, `POST /api/auth/reproof` with a TOTP code, security key or + * passkey stamps it again. + * + * Cookie session, account WITHOUT a second factor: any of the session's + * creation (a sign-in), `mfaVerifiedAt` (a passkey sign-in or re-proof) or + * `reproofAt` (a password re-proof) within five minutes. Such an account + * re-enters its password, or uses a passkey. + * + * The refusal is 401 `auth.reproof.required` with `meta.methods`, the proofs + * this account can give; the web opens the re-proof dialog on it and retries. + * An empty list means the account holds nothing to re-prove in place (an + * SSO-only account), and signing in again is the way through. + * + * Returns the caller plus `commitElevation`, which spends a Bearer elevation + * and is a no-op otherwise. Call it right before acting. + */ +export async function requireRecentProof(options: { + bearer: RecentProofBearerRule; +}): Promise { + const auth = await requireAuth(); + const noop = async () => {}; + + if (auth.authMethod === "cookie") { + await assertRecentCookieProof(auth.user, auth.session.id); + return { ...auth, commitElevation: noop }; + } + + const enrolled = await hasSecondFactorEnrolled(auth.user); + if (options.bearer === "token") { + annotate({ meta: { recent_proof: "bearer_token" } }); + return { ...auth, commitElevation: noop }; + } + if (options.bearer === "elevation-if-enrolled" && !enrolled) { + annotate({ meta: { recent_proof: "bearer_token" } }); + return { ...auth, commitElevation: noop }; + } + const commitElevation = await resolveBearerElevation( + { user: auth.user, apiTokenId: auth.session.id }, + { freshFactor: enrolled, proofSource: "second-factor" }, + ); + return { ...auth, commitElevation }; +} + +/** + * The cookie arm of {@link requireRecentProof}, for routes that resolve the + * caller themselves (the admin routes, which must stay on `requireAdmin`). + */ +export async function assertRecentCookieProof( + user: SessionUser, + sessionId: string, +): Promise { + const enrolled = await hasSecondFactorEnrolled(user); + const row = await prisma.session.findUnique({ + where: { id: sessionId }, + select: { mfaVerifiedAt: true, createdAt: true, reproofAt: true }, + }); + const now = Date.now(); + const fresh = (d: Date | null | undefined) => + d != null && now - d.getTime() <= MFA_STEP_UP_MAX_AGE_SECONDS * 1000; + const satisfied = enrolled + ? fresh(row?.mfaVerifiedAt) + : fresh(row?.mfaVerifiedAt) || + fresh(row?.createdAt) || + fresh(row?.reproofAt); + if (satisfied) { + annotate({ meta: { recent_proof: "session" } }); + return; + } + const methods = await recentProofMethods(user, enrolled); + annotate({ + action: { name: "auth.reproof.required" }, + meta: { second_factor: enrolled, methods: methods.join(",") }, + }); + throw new StepUpRequiredError( + REPROOF_REQUIRED_CODE, + "Confirm it is you to continue", + { methods }, + ); +} diff --git a/src/lib/auth/guards/record.ts b/src/lib/auth/guards/record.ts new file mode 100644 index 000000000..30e1e68b0 --- /dev/null +++ b/src/lib/auth/guards/record.ts @@ -0,0 +1,390 @@ +import type { AccountGrant } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { SESSION_USER_SELECT, type SessionUser } from "@/lib/auth/session-user"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { recordDelegatedAccess } from "@/lib/auth/audit"; +import { + readSelectorHeader, + selectorNamesAnAccount, + type ActingCarrier, +} from "@/lib/auth/acting-carrier"; +import { meterAccountWrite } from "@/lib/auth/write-meter"; +import { + findActiveGrant, + grantAllows, + grantCoversDomain, + touchGrantUsage, + type GrantNeed, +} from "@/lib/sharing/grants"; +import type { ShareScope } from "@/lib/sharing/scope"; +import { assertRecordSessionFence } from "@/lib/sharing/record-session-fence"; +import { + SharingAccessDeniedError, + SharingNotPermittedError, +} from "@/lib/api-errors"; + +import { + auditRefusal, + readActingCarrier, + refuseUndeclaredMode, +} from "./acting"; +import { authenticateCaller } from "./caller"; +import { + READ_HTTP_METHODS, + isScopedCredential, + type AuthContext, +} from "./context"; + +/** + * An actor surface: this route always serves the CALLER's own rows. + * + * It works under a switch. That is the whole reason the mode exists: the + * switcher itself, the banner, and the "which records may I open" list all have + * to keep answering while the browser is acting as someone else, and every one + * of them is about the delegate. So the session carrier is read and + * deliberately ignored — no query, because there is nothing the answer could + * change. + * + * A selector HEADER is refused, loudly. An actor surface is DEFINED as serving + * the caller's own rows, so a client that attaches a selector to one has a bug, + * and the bug is invisible until somebody files a support ticket about data + * that looks wrong. Ignoring the header would serve exactly the right response + * to exactly the wrong expectation. + */ +export async function requireActorAuth(): Promise { + const auth = await authenticateCaller(); + const header = await readSelectorHeader(); + if (header !== null) { + annotate({ meta: { sharing_refusal: "actor_surface" } }); + auditRefusal(auth, { kind: "header", accountId: header }, "actor_surface"); + throw new SharingNotPermittedError(); + } + await meterAccountWrite(auth.user.id); + return auth; +} + +/** + * A request resolved against a record that may not be the caller's own. + * + * `user` is the account whose rows the handler must read and write — so an + * existing `where: { userId: user.id }` is correct without being touched, which + * is what keeps a route migration a one-line change instead of an audit. + * `actor` is who is actually here. They are the same object when no switch is + * active. + */ +export interface RecordAuthContext extends AuthContext { + /** The authenticated caller. Equals `user` when the caller acts as themselves. */ + readonly actor: SessionUser; + /** The grant being exercised, or null when the caller acts as themselves. */ + readonly grantId: string | null; +} + +/** What a delegable route may additionally declare about the credentials it takes. */ +export interface RecordAuthOptions { + /** + * A Bearer scope this route accepts in place of the cookie-equivalent + * default. Naming one is a WIDENING — it admits a credential class the route + * refused before — and is frozen by `bearer-scope-enforcement-guard.test.ts` + * exactly like a `requireAuth` scope, so it lands in a reviewed diff. + * + * What the option does NOT do is relax anything below it. A credential + * admitted this way is single-purpose: it reaches this route on its OWN + * record and is refused the moment the request names another, before any + * grant is read. The `need` and `domain` checks run unchanged for everyone + * else. + */ + readonly scope?: string; + /** + * This route answers a POST without changing anything: the body + * carries a query that must not stand in a URL (the record search, whose + * text is health data a proxy's access log would otherwise keep). Under a + * route declaring `"read"`, a POST is then read at the grant's read level + * instead of being escalated to a write. + * + * Like `scope`, naming it is a WIDENING and is frozen by + * `read-over-post-guard.test.ts`, so it lands in a reviewed diff. It does + * nothing for any other method, and nothing for a route declaring a need + * above `"read"`. + */ + readonly readOverPost?: true; + /** + * This route meters its writes with a dedicated bucket instead of the shared + * per-account one (`meterAccountWrite`). Frozen per route, with the reason, + * by `write-rate-limit-guard.test.ts`, which also requires the route to call + * its own limiter. + */ + readonly writeBucket?: "own"; +} + +/** + * A delegable surface: this route may act on a shared record. + * + * Calling it is a DECLARATION, reviewed and frozen by a structural guard, that + * the route reads or writes health data and nothing else — no credential, no + * integration, no notification channel, no grant management, nothing that would + * let a delegate extend their own reach. The declaration is the security + * property; this function only enforces what was declared. + * + * @param need what the route does to the record. `"read"` still refuses a + * non-safe HTTP method under a read-only grant: the declaration and the + * method must BOTH be satisfiable, so a delegable GET handler that grows a + * POST export cannot quietly inherit the read grant's permission. An unknown + * method (no event context) counts as a write and is refused, the same + * fail-closed posture as the MCP audience guard in `caller.ts`. A route declaring + * `"manage"` refuses everything below it regardless of method: the method + * can only escalate what the declaration asked for, never satisfy it. + * @param domain v1.37.0 — which section of the record the route touches, or + * `"record"` when it reads across sections. Required, and the requirement is + * the fail-closed lever: a delegable route without a classification does not + * typecheck, so the set of routes carrying one cannot fall behind the set of + * routes that are delegable. The value is frozen per module by + * `src/__tests__/sharing-surface-guard.test.ts` and reviewed against the + * design's clustering table; this function only enforces what was declared. + * @param options v1.38.x — optional extra declarations. Today that is `scope`, + * naming a narrow Bearer this route admits alongside cookie sessions and + * cookie-equivalent tokens. A route that passes none keeps the fail-closed + * default and refuses every narrow token, which is what makes adding one a + * visible decision rather than an omission. + */ +export async function requireRecordAuth( + need: GrantNeed, + domain: ShareScope, + options?: RecordAuthOptions, +): Promise { + const auth = await authenticateCaller(options?.scope); + // Before any carrier is read, any grant is looked up, or any record + // row is touched: does this request still believe what its session believes? + // + // The atomicity that makes this a tautology rather than a race: the epoch the + // fence compares and the `actingAsUserId` the carrier below is built from are + // BOTH projected off the one session row `authenticateCaller()` already + // loaded. The scope this handler serves under and the epoch it validated are + // therefore the same fact read at the same instant, not two reads a commit + // can land between. Do not add a second session read here to "refresh" it — + // that would reintroduce exactly the window this ordering removes. + // + // `authenticateCaller` necessarily runs first because it supplies the row the + // fence reads, so the invariant is "before the carrier", not "before the + // first database await". + await assertRecordSessionFence(auth); + // The shared write ceiling, charged to the ACTING account before any grant + // is read, so a refused write counts like an admitted one — see + // `meterAccountWrite`. + await meterAccountWrite(auth.user.id, { + readOverPost: options?.readOverPost, + writeBucket: options?.writeBucket, + }); + const carrier = await readActingCarrier(auth); + + // A narrow, single-purpose Bearer never acts on another record. + // + // Its holder mints it themselves, for one job, and pastes it into something + // that is not this app — a scale bridge, a home-automation rule. Admitting it + // under a selector would exercise somebody else's grant through a credential + // nobody reviewed for that purpose, and the blast radius of the paste would + // stop being "my own record" without anyone deciding it should. + // + // Placed above the `none` arm and below the carrier read, which is the only + // position that is both correct and cheap: no grant is looked up on the way + // to the refusal, and a scoped credential on its own record still falls + // through to the untouched `none` path below. A `["*"]` token and a cookie + // session read `bearerScope: null` here and keep exactly the delegation + // behaviour they had. + if (carrier.kind !== "none" && isScopedCredential(auth)) { + throw refuseUndeclaredMode(auth, carrier, "narrow_scope_selector"); + } + + if (carrier.kind === "none") { + // Do no harm: without a carrier this is byte-for-byte the pre-v1.36.0 + // request, resolved by the same `authenticateCaller` every other mode uses. + getEvent()?.setProviderWorkAuthority({ + origin: "owner", + recordUserId: auth.user.id, + actorUserId: auth.user.id, + grantId: null, + }); + return { ...auth, actor: auth.user, grantId: null }; + } + if (carrier.kind === "misplaced-header") { + // The route DID declare a mode; the client sent the selector over the + // cookie transport, where the carrier is the session row. + throw refuseUndeclaredMode(auth, carrier, "misplaced_selector"); + } + + const method = (getEvent()?.getHttpMethod() ?? "").toUpperCase(); + const readsOnly = + READ_HTTP_METHODS.has(method) || + (options?.readOverPost === true && method === "POST"); + const effectiveNeed: GrantNeed = + need !== "read" || !readsOnly ? escalate(need) : "read"; + + return resolveSwitchedRecord(auth, carrier, (grant) => { + if (!grantAllows(grant, effectiveNeed)) return "insufficient_access"; + // Level first, then scope, and the order is visible on the wire in exactly + // one way: not at all. Both refusals are the same error with the same + // bytes, and only the audit reason differs — so the ordering is a choice + // about what the operator's trail says happened, not about what the caller + // learns. It says the bigger thing: a delegate who was never given this + // level is a different story from one whose sections do not reach here. + if (!grantCoversDomain(grant, domain)) return "out_of_scope"; + return null; + }); +} + +/** + * The need a non-safe method implies, for a route that declared `need`. + * + * A PUT on a route declaring `"read"` needs a write grant, which is what this + * has always done. A PUT on a route declaring `"manage"` needs a MANAGE grant + * and must not be reduced to a write — so the escalation raises the floor to + * `"write"` and never lowers a declaration that already sits above it. + */ +function escalate(need: GrantNeed): GrantNeed { + return need === "manage" ? "manage" : "write"; +} + +/** + * A guardian surface: this route administers a managed profile. + * + * The other declaration, and a separate function rather than a flag on the one + * above, for the reason `requireActorAuth` is also separate: the guard freezes + * each declaration as its own list, so a route moving between them is a diff a + * human reviews rather than an argument about a parameter. + * + * What it admits is narrower than it looks. An active MANAGE grant is + * necessary and NOT sufficient — the record must also carry the managed-profile + * marker. That second condition is the identity fence, and it is the line + * between "manage my data" and "own my account": an adult who granted + * management of their own record reaches none of this, at any level, by any + * argument, because their record has no marker and never will while they hold + * it. A managed profile has no self to reserve these surfaces for, so its + * guardian holds them. + * + * The fence is gated on the marker rather than on the grant precisely so that + * no future change to what MANAGE means can reach through it. Widening MANAGE + * would widen `requireRecordAuth`; it would not touch this function. + */ +export async function requireGuardianAuth(): Promise { + const auth = await authenticateCaller(); + // Same placement and the same reason as `requireRecordAuth` above: the + // guardian surfaces administer a managed profile, so a request arriving under + // a context that has since moved must be refused before the marker is read, + // not after. + await assertRecordSessionFence(auth); + await meterAccountWrite(auth.user.id); + const carrier = await readActingCarrier(auth); + + if (carrier.kind === "none") { + // The owner of an ordinary record, reaching their own settings. Same + // do-no-harm posture as the record resolver: without a carrier this is the + // request it always was. + return { ...auth, actor: auth.user, grantId: null }; + } + if (carrier.kind === "misplaced-header") { + throw refuseUndeclaredMode(auth, carrier, "misplaced_selector"); + } + + return resolveSwitchedRecord(auth, carrier, (grant, owner) => { + if (!grantAllows(grant, "manage")) return "insufficient_access"; + if (owner.managedProfileAt === null) return "guardian_only"; + return null; + }); +} + +/** + * The shared body of both record resolvers: turn a selector into an owner, or + * refuse. + * + * Everything either resolver does with a carrier lives here except the + * admission rule itself, which arrives as `admit` — a function of the grant + * and the owner returning an audit reason to refuse with, or null to proceed. + * The split is where it is because the two resolvers differ in exactly that + * one decision and in nothing else, and two copies of the grant lookup, the + * refusal shape, the usage stamp and the owner's access row would be two + * copies to keep in step. + * + * The owner row is loaded BEFORE the admission runs, which is a change from + * the first resolver's ordering and a deliberate one: the guardian rule is a fact about + * the owner, so it cannot be decided before the owner is known. Nothing about + * the refusal moved — every arm below is the same error with the same bytes, + * and an admission that refuses after the load costs one query it does not use + * and tells the caller nothing extra. + */ +async function resolveSwitchedRecord( + auth: AuthContext, + carrier: Extract, + admit: (grant: AccountGrant, owner: SessionUser) => string | null, +): Promise { + /** Every refusal below is this one error, with the reason kept off the wire. */ + const denied = (reason: string): SharingAccessDeniedError => { + annotate({ meta: { sharing_refusal: reason } }); + auditRefusal(auth, carrier, reason); + return new SharingAccessDeniedError(); + }; + + if (!selectorNamesAnAccount(carrier.accountId)) { + throw denied("malformed_selector"); + } + + // Loaded here, on this request, every request. Not memoised on the session, + // not cached in the process, not carried on the token: a revocation has to + // land on the delegate's NEXT request, not on their next login. That is the + // one property the owner is actually promised when they press revoke. + // + // Note what is NOT looked up: the account named by the carrier. An account + // that does not exist and an account that granted nothing produce the same + // empty row from the same query, which is what makes the two refusals + // identical in bytes and in timing rather than identical by careful wording. + const grant = await findActiveGrant({ + grantorId: carrier.accountId, + granteeId: auth.user.id, + }); + if (!grant) throw denied("no_active_grant"); + + const owner = await prisma.user.findUnique({ + where: { id: grant.grantorId }, + select: SESSION_USER_SELECT, + }); + // Unreachable while the FK cascade holds — a grant naming a deleted account + // is deleted with it. Fail closed anyway: an authorization we cannot resolve + // to a person is not one to act on. + if (!owner) throw denied("owner_missing"); + + const refusal = admit(grant, owner); + if (refusal !== null) throw denied(refusal); + + getEvent()?.setActingAs(owner.id); + // And whether this request may spend the owner's provider budget. + // MANAGE opens the generated reads; it does not open the generation behind + // them, because the owner would pay for and consent to an egress they did + // not cause. A managed profile is the exception and the reason the marker is + // read here rather than the grant: it has no self to protect from its own + // guardian. Stamped where the owner row is already loaded, so no generator + // has to be told; see `src/lib/sharing/delegated-generation.ts`. + if (owner.id !== auth.user.id && owner.managedProfileAt === null) { + getEvent()?.setDelegatedGenerationSuppressed(); + } + getEvent()?.setProviderWorkAuthority({ + origin: owner.managedProfileAt === null ? "delegate" : "guardian", + recordUserId: owner.id, + actorUserId: auth.user.id, + grantId: grant.id, + }); + // Fire-and-forget, the `ApiToken.lastUsedAt` posture: the read must not wait + // on the bookkeeping, and the bookkeeping failing must not fail the read. + void touchGrantUsage(grant.id); + // The owner's copy of the same fact, and the one they can read: a day- + // coalesced row on their own record saying who opened it. It lands here + // rather than in the handlers because a route cannot forget what it never + // had to remember — every delegated request in the product passes through + // this line, and only requests that got past the grant check reach it. + void recordDelegatedAccess(owner.id, auth.user.id); + + return { + session: auth.session, + user: owner, + authMethod: auth.authMethod, + actor: auth.user, + grantId: grant.id, + }; +} diff --git a/src/lib/auth/second-factor.ts b/src/lib/auth/second-factor.ts index b28935755..4eeccf4f0 100644 --- a/src/lib/auth/second-factor.ts +++ b/src/lib/auth/second-factor.ts @@ -18,7 +18,7 @@ import { prisma } from "@/lib/db"; * a step-up at all. Counting a passkey here would silently pull every * passkey-holding account into a gate those routes have never applied to them — * a behaviour change to five destructive actions, made in passing, for the - * benefit of a sixth. `canProveFreshFactor` in `api-handler.ts` is where the wider question + * benefit of a sixth. `canProveFreshFactor` in `auth/guards/fresh-factor.ts` is where the wider question * lives. * * Since v1.43.3 a passkey DOES count as a second factor where the gate is diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 06ec6381b..6fdfea5c3 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -446,7 +446,7 @@ export async function getSession(): Promise<{ // already in hand and re-reading it per request would be waste. // // Read this in exactly one place: the acting-account resolver in - // `src/lib/api-handler.ts`. It is a selector and not a decision — the + // `src/lib/auth/guards/`. It is a selector and not a decision — the // resolver still has to find a live grant for the pair before anything // is substituted, and a value stranded here by a grant that has since // ended confers nothing. diff --git a/src/lib/auth/write-meter.ts b/src/lib/auth/write-meter.ts index 8743739eb..e5116822e 100644 --- a/src/lib/auth/write-meter.ts +++ b/src/lib/auth/write-meter.ts @@ -7,7 +7,7 @@ * PATCH and DELETE on an `[id]` among them. A positive list of routes that * remember to call it can only fall behind the routes that exist, so the * ceiling now sits on the path every authenticated write already takes: the - * identity resolvers in `src/lib/api-handler.ts` call {@link meterAccountWrite} + * identity resolvers in `src/lib/auth/guards/` call {@link meterAccountWrite} * once the caller is known, and it charges the bucket for any request whose * method writes. * diff --git a/src/lib/sharing/account-access.ts b/src/lib/sharing/account-access.ts index c46885ab9..432dd8d1b 100644 --- a/src/lib/sharing/account-access.ts +++ b/src/lib/sharing/account-access.ts @@ -8,7 +8,7 @@ * That is the whole design of this file and it is worth stating plainly, * because the tempting alternative is one line shorter: publish the grant rows * and let the client work out what they mean. Then two programs decide one - * person's access — the resolver in `src/lib/api-handler.ts`, and whichever + * person's access — the resolver in `src/lib/auth/guards/record.ts`, and whichever * client is being read today — and they answer differently the first time an * expiry, a revocation or a second access level appears. The client renders * `canSwitch` and `canWrite`; it never computes them, and there is nothing in diff --git a/src/lib/sharing/acting-session.ts b/src/lib/sharing/acting-session.ts index 9d1bfcabe..e92862975 100644 --- a/src/lib/sharing/acting-session.ts +++ b/src/lib/sharing/acting-session.ts @@ -6,7 +6,7 @@ * session at a record, and the end of a grant points every affected session * back at its own. Both live here rather than at their call sites so the * carrier has one writer, the same way it has one reader (the resolver in - * `src/lib/api-handler.ts`). The structural guard in + * `src/lib/auth/guards/acting.ts`). The structural guard in * `src/__tests__/acting-account-boundary-guard.test.ts` holds that shape: any * other file that so much as names the column fails it. * diff --git a/src/lib/sharing/record-session-fence.ts b/src/lib/sharing/record-session-fence.ts index 9fe513050..071cf9a28 100644 --- a/src/lib/sharing/record-session-fence.ts +++ b/src/lib/sharing/record-session-fence.ts @@ -198,7 +198,7 @@ export async function assertRecordSessionFence( // two-refusals note in the contract module. // // No audit row, and that follows the rule already written for the session - // carrier in `api-handler.ts`: a HEADER refusal is a per-request assertion by + // carrier in `auth/guards/acting.ts`: a HEADER refusal is a per-request assertion by // a client and earns a durable record, a SESSION-state refusal is ambient // browser state and would bury the rows that matter under noise from the // feature working as designed. This arm is the second kind — every