diff --git a/src/__tests__/acting-account-boundary-guard.test.ts b/src/__tests__/acting-account-boundary-guard.test.ts index 1b01ae55f..c842443b8 100644 --- a/src/__tests__/acting-account-boundary-guard.test.ts +++ b/src/__tests__/acting-account-boundary-guard.test.ts @@ -113,7 +113,7 @@ describe("the acting-account carrier is read in one place", () => { const namers = filesMatching(/x-healthlog-account/i); expect(namers.length).toBeGreaterThan(0); expect(namers).toEqual([ - "lib/api-handler.ts", + "lib/auth/acting-carrier.ts", "lib/openapi/routes/account-sharing.ts", ]); }); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index e4899cbcb..187b362f5 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -19,6 +19,13 @@ import { STEP_UP_ELEVATION_TTL_SECONDS, } from "./auth/step-up"; import { hashToken } from "./auth/hmac"; +import { + carrierTarget, + decideActingCarrier, + readSelectorHeader, + selectorNamesAnAccount, + type ActingCarrier, +} from "./auth/acting-carrier"; import { AssistantDisabledError } from "./feature-flags"; import { ConsentRequiredError } from "./ai/consent-guard"; import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "./mcp/oauth/config"; @@ -597,69 +604,23 @@ async function authenticateBearer( // // 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 here because they are two -// answers to one question, and two answers that live apart drift. +// 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. // -// Neither carrier authorises anything. Both merely NAME an account; the grant -// table decides, on this request, with no cached verdict anywhere. The -// difference between them is who can set them: -// -// * The cookie transport carries it on the session ROW (`actingAsUserId`). -// A browser cannot address that row — the cookie holds a secret whose hash -// is the lookup key — so the value is server state, written only by the -// switch endpoint after it has validated the grant. -// * The Bearer transport carries it in a per-request HEADER. A long-lived -// token must not accumulate switch state: stamping it on the ApiToken row -// would make the credential itself ambient authority, and the token has to -// keep meaning "this person" for as long as it exists. -// -// The header is a Bearer-only carrier. On a cookie request it is refused, not -// ignored — a client that sends a selector over a transport that does not read -// one believes it is selecting an account and is not, and the failure of that -// belief is a support case about data that looks wrong. +// That decision now lives in `./auth/acting-carrier`, because a second caller +// needs it: the idempotency wrapper files its replay cell under the record a +// request claims, and it runs before any auth context exists. What stays here +// is what only this file may do — turning a claim into a data scope after a +// live grant says so. /** - * The per-request account selector, on the Bearer transport only. + * The selector header's name, re-exported. * - * Cross-origin JavaScript cannot set it: a custom request header forces a CORS - * preflight and this app sends no CORS headers at all, so the preflight is - * never answered. It is a selector regardless — the grant table is what - * authorises, and the header is treated as hostile input everywhere below. + * It is declared beside the carrier reader it belongs to; every route-facing + * helper is imported from this module, and the OpenAPI table and the suites + * that drive a switched request were written against that path. */ -export const ACCOUNT_SELECTOR_HEADER = "x-healthlog-account"; - -/** - * Longest selector value worth a database round trip. Account ids are 25-char - * cuids; anything past this names no account that could exist and is refused - * as such, on the same path and with the same bytes as an unknown account. - */ -const MAX_SELECTOR_LENGTH = 64; - -/** - * What, if anything, this request says it is acting as. - * - * `misplaced-header` is its own case rather than an error thrown at read time - * so that every mode below decides for itself — the modes disagree about the - * session carrier and must not disagree about this one by accident. - */ -type ActingCarrier = - | { kind: "none" } - | { kind: "session"; accountId: string } - | { kind: "header"; accountId: string } - | { kind: "misplaced-header" }; - -/** The selector header, or null when the request did not send one. */ -async function readSelectorHeader(): Promise { - try { - const headerList = await headers(); - return headerList.get(ACCOUNT_SELECTOR_HEADER); - } catch { - // Outside a Next.js request scope (direct unit invocation of a legacy - // route) there is no header to read. Same posture as the Bearer branch of - // `authenticateCaller`. - return null; - } -} +export { ACCOUNT_SELECTOR_HEADER } from "./auth/acting-carrier"; /** * The acting-account carrier for this request. @@ -677,32 +638,11 @@ async function readSelectorHeader(): Promise { * next request rather than from the next login. */ async function readActingCarrier(auth: AuthContext): Promise { - const header = await readSelectorHeader(); - - if (auth.authMethod === "bearer") { - // No session row exists on this transport, so the header is the only - // carrier there could be. - return header === null - ? { kind: "none" } - : { kind: "header", accountId: header }; - } - - if (header !== null) return { kind: "misplaced-header" }; - - const stamped = auth.session.actingAsUserId ?? null; - return stamped === null - ? { kind: "none" } - : { kind: "session", accountId: stamped }; -} - -/** Could this value name an account at all? */ -function selectorNamesAnAccount(value: string): boolean { - return value.length > 0 && value.length <= MAX_SELECTOR_LENGTH; -} - -/** The account a carrier names, or null when it names nothing. */ -function carrierTarget(carrier: ActingCarrier): string | null { - return "accountId" in carrier ? carrier.accountId : null; + return decideActingCarrier({ + transport: auth.authMethod === "bearer" ? "bearer" : "cookie", + stamped: auth.session.actingAsUserId ?? null, + header: await readSelectorHeader(), + }); } /** diff --git a/src/lib/auth/acting-carrier.ts b/src/lib/auth/acting-carrier.ts new file mode 100644 index 000000000..9be1e116d --- /dev/null +++ b/src/lib/auth/acting-carrier.ts @@ -0,0 +1,105 @@ +/** + * What a request says it is acting as. + * + * It lives apart from `requireRecordAuth`, which acts on the answer, because + * asking the question and deciding what it permits are different jobs and the + * asking is about to have more than one caller. + * + * The claim is a SELECTOR and never an authorisation. Nothing in this file + * looks at a grant, and no caller may treat a carrier as permission: the + * account named here is an id to be checked. What it decides is only which + * account is being named, and by which transport — the question `grants.ts` + * warns must have exactly one answer, because a second implementation of it + * drifts and the copy that drifts is the one nobody reads. + * + * The transport rules, unchanged from where they were first written: + * + * * The cookie transport carries the claim on the session ROW. A browser + * cannot address that row, so the value is server state written only by + * the switch endpoint after it validated a grant. + * * The Bearer transport carries it in a per-request HEADER. A long-lived + * token must not accumulate switch state. + * * The header on a cookie request is `misplaced`, not ignored — a client + * that believes it is selecting an account and is not produces a support + * case about data that looks wrong. + */ +import { headers } from "next/headers"; + +/** + * The per-request account selector, on the Bearer transport only. + * + * Cross-origin JavaScript cannot set it: a custom request header forces a CORS + * preflight and this app sends no CORS headers at all, so the preflight is + * never answered. It is a selector regardless — the grant table is what + * authorises, and the header is treated as hostile input everywhere it is read. + */ +export const ACCOUNT_SELECTOR_HEADER = "x-healthlog-account"; + +/** + * Longest selector value worth a database round trip. Account ids are 25-char + * cuids; anything past this names no account that could exist and is refused + * as such, on the same path and with the same bytes as an unknown account. + */ +export const MAX_SELECTOR_LENGTH = 64; + +/** + * What, if anything, this request says it is acting as. + * + * `misplaced-header` is its own case rather than an error thrown at read time + * so that every caller decides for itself — the callers disagree about what to + * do with a misplaced claim and must not disagree about detecting one. + */ +export type ActingCarrier = + | { kind: "none" } + | { kind: "session"; accountId: string } + | { kind: "header"; accountId: string } + | { kind: "misplaced-header" }; + +/** The selector header, or null when the request did not send one. */ +export async function readSelectorHeader(): Promise { + try { + const headerList = await headers(); + return headerList.get(ACCOUNT_SELECTOR_HEADER); + } catch { + // Outside a Next.js request scope (direct unit invocation of a legacy + // route) there is no header to read. Same posture as the Bearer branch of + // `authenticateCaller`. + return null; + } +} + +/** + * The carrier decision itself, as a pure function of what the transport + * carried. Pure on purpose: it is the one statement of the rule, and a rule + * that needs a request context to be read is a rule that gets re-implemented. + */ +export function decideActingCarrier(input: { + transport: "cookie" | "bearer"; + /** The session row's stamp. Ignored on the Bearer transport, which has none. */ + stamped: string | null; + header: string | null; +}): ActingCarrier { + if (input.transport === "bearer") { + // No session row exists on this transport, so the header is the only + // carrier there could be. + return input.header === null + ? { kind: "none" } + : { kind: "header", accountId: input.header }; + } + + if (input.header !== null) return { kind: "misplaced-header" }; + + return input.stamped === null + ? { kind: "none" } + : { kind: "session", accountId: input.stamped }; +} + +/** The account a carrier names, or null when it names nothing. */ +export function carrierTarget(carrier: ActingCarrier): string | null { + return "accountId" in carrier ? carrier.accountId : null; +} + +/** Could this value name an account at all? */ +export function selectorNamesAnAccount(value: string): boolean { + return value.length > 0 && value.length <= MAX_SELECTOR_LENGTH; +}