mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
refactor(auth): move the request guards out of api-handler into lib/auth/guards
api-handler.ts held two modules: the route wrapper and thirteen guards. The guards now live in lib/auth/guards/, one module per family (the caller, the acting-account carrier, the record modes, the cookie-only helpers, the fresh-factor and recent-proof gates, the Bearer elevation, MFA management). api-handler.ts keeps the wrapper and re-exports every guard, so it stays the one public entry and no route changes its import. The bodies move verbatim; only the version prefixes of moved comments are dropped. The structural guards that read the auth layer now find the module that declares a guard by name (helpers/auth-guards.ts) instead of a fixed path, and a new guard holds the entry: nothing outside the folder but api-handler.ts imports a guard module.
This commit is contained in:
1 parent
9de64cae3e
commit
ee1561496b
40 files changed
+2153
-1668
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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)}`);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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<string, string>();
|
||||
const namespaces = new Set<string>();
|
||||
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<string>([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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string> = new Set<string>([
|
||||
* "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");
|
||||
});
|
||||
|
||||
@@ -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(");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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*\(/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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\(\)/);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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).
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+57
-1538
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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<ActingCarrier> {
|
||||
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();
|
||||
}
|
||||
@@ -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<AuthContext> {
|
||||
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<AuthContext> {
|
||||
// 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<AuthContext> {
|
||||
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<BearerAuthContext> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<AuthContext> {
|
||||
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<AuthContext> {
|
||||
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" };
|
||||
}
|
||||
@@ -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<void>> {
|
||||
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 },
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
@@ -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<FreshMfaContext> {
|
||||
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<AuthContext> {
|
||||
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<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<ErasureAuthContext> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<void>;
|
||||
} & (
|
||||
| { 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<MfaManagementContext> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<ErasureAuthContext> {
|
||||
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<void> {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
@@ -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<AuthContext> {
|
||||
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<RecordAuthContext> {
|
||||
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<RecordAuthContext> {
|
||||
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<ActingCarrier, { accountId: string }>,
|
||||
admit: (grant: AccountGrant, owner: SessionUser) => string | null,
|
||||
): Promise<RecordAuthContext> {
|
||||
/** 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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user