Files
HealthLog/src/lib/api-handler.ts
T
Marc-André Bombeck 45fec0fc1b feat(02-12.1): refuse a record request whose context has moved
- both record resolvers check the fence after authentication and before
  the carrier, off the one session row that also decides the scope
- POST /api/account/switch takes an optional expectedEpoch and writes
  conditionally, so concurrent switchers resolve to one outcome
- GET /api/auth/me publishes the context a browser bootstraps from
- every response that resolved a record scope echoes it back
- regenerate the API contract
2026-08-06 18:21:40 +02:00

1540 lines
64 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import type { AccountGrant, User } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { WideEventBuilder } from "./logging/event-builder";
import { annotate, eventStorage, getEvent } from "./logging/context";
import { emitIfSampled } from "./logging/transports";
import { redactOptional, redactSecrets } from "./logging/redact";
import { getSession } from "./auth/session";
import { auditLog, recordDelegatedAccess } from "./auth/audit";
import {
resolveBearerToken,
BearerAuthError,
type ScopeRequirement,
} from "./auth/bearer";
import {
claimStepUpElevation,
validateStepUpElevation,
STEP_UP_ELEVATION_TTL_SECONDS,
} from "./auth/step-up";
import { hashToken } from "./auth/hmac";
import {
carrierTarget,
decideActingCarrier,
readSelectorHeader,
selectorNamesAnAccount,
type ActingCarrier,
} from "./auth/acting-carrier";
import { AssistantDisabledError } from "./feature-flags";
import { ConsentRequiredError } from "./ai/consent-guard";
import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "./mcp/oauth/config";
import {
findActiveGrant,
grantAllows,
grantCoversDomain,
touchGrantUsage,
type GrantNeed,
} from "./sharing/grants";
import type { ShareScope } from "./sharing/scope";
import { RECORD_FENCE_ERROR_CODE } from "./sharing/record-session-fence-contract";
import {
assertRecordSessionFence,
attachRecordContextEcho,
} from "./sharing/record-session-fence";
/**
* HTTP methods a read-only credential may use on the REST surface. A request
* with any other method (POST / PUT / PATCH / DELETE) is a write.
*
* The MCP-audience guard below assumes these methods are side-effect-free:
* an MCP-bound token is admitted on them. A future side-effecting GET (or HEAD)
* would silently widen that token's reach over REST — do NOT add one without
* revisiting the MCP audience binding here.
*/
const READ_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
/**
* Whether a token is MCP-audience-bound (H1). The MCP OAuth bridge and the
* connector settings card mint tokens whose ONLY grants are `health:read` —
* and, when the user consents to logging, `health:read health:write`. Either
* shape is bound to the MCP surface: the `/mcp` resolver accepts it and so do
* safe (read) REST methods, but it must NEVER reach a REST write/delete. The
* `health:write` grant admits writes ONLY in-process over `/mcp` (the confirmed
* write tools), never over REST — so a write-scoped MCP token is exactly as
* audience-bound on this edge as a read-only one. A token carrying any broader
* or legacy grant (`*`, `medication:ingest`, …) is NOT MCP-audience-bound and
* keeps its existing reach.
*/
export function isMcpAudienceToken(permissions: readonly string[]): boolean {
if (permissions.length === 0) return false;
return permissions.every(
(p) => p === SCOPE_HEALTH_READ || p === SCOPE_HEALTH_WRITE,
);
}
/**
* Custom error class for HTTP errors with status codes.
* Throw inside apiHandler-wrapped routes to return a JSON error response.
*/
export class HttpError extends Error {
constructor(
public statusCode: number,
message: string,
) {
super(message);
this.name = "HttpError";
}
}
/**
* v1.36.0 — the two refusals the acting-account resolver can raise.
*
* Both are 403 with a stable `meta.errorCode`, the same envelope shape the
* step-up and consent gates already use, so a client branches on the code
* rather than on prose.
*/
export abstract class SharingAuthError extends HttpError {
abstract readonly errorCode: string;
}
/**
* "You may not act as that account."
*
* Deliberately parameterless. The message is a fixed literal and the
* constructor takes no arguments, so there is no way for a caller to vary the
* response — which is what makes a selector naming an account that does not
* exist and one naming an account that granted nothing produce the SAME BYTES.
* That is not a formatting nicety: a distinguishable refusal turns this
* endpoint into an account-enumeration oracle for anyone with a login.
*
* The indistinguishability is structural rather than careful, too. Neither
* branch ever looks an account up: the resolver asks the grant table for the
* (owner, delegate) pair and a missing account and a missing grant are the
* same empty result, on the same code path, after the same one query.
*
* Why the reason is not in the response at all: the caller already knows what
* they sent, and the only party who benefits from knowing WHICH condition
* failed is someone probing. The reason goes to the audit row and the wide
* event, where the owner and the operator can see it.
*/
export class SharingAccessDeniedError extends SharingAuthError {
readonly errorCode = "sharing.access.denied";
constructor() {
super(403, "Account access denied");
this.name = "SharingAccessDeniedError";
}
}
/**
* "This endpoint does not act on another account's record."
*
* A different fact from the one above and so a different code: nothing here is
* about whether a grant exists. The route simply never declared that it can be
* used under a switch, so it refuses rather than quietly serving the caller's
* OWN record — which is the reverse data-mixing failure, and the reason this
* class exists at all. A delegate who believes they are reading the owner's
* record must never be handed their own instead.
*/
export class SharingNotPermittedError extends SharingAuthError {
readonly errorCode = "sharing.not_permitted";
constructor() {
super(403, "This endpoint cannot be used while acting on another account");
this.name = "SharingNotPermittedError";
}
}
/**
* v1.37.0 — "the record this session is on is not the record you asserted."
*
* A third sibling rather than a branch, so it rides the existing
* `SharingAuthError` arm below with no new serialisation code. The status is
* the one thing that differs and it differs on purpose: 409, not 403, because
* this is not a refusal of access. The caller may well be entitled to both
* records. What failed is agreement about WHICH one, and the instruction the
* code carries is "reconcile through `/api/auth/me` and try again", not "you
* have lost this record".
*
* That distinction is load-bearing for deploy compatibility: the currently
* shipped bundle reacts to `sharing.access.denied` by leaving the record and
* hard-navigating, which is exactly the wrong response to a transient
* disagreement and exactly the right one for a bundle too old to reconcile. See
* `src/lib/sharing/record-session-fence-contract.ts` for why the fence hands
* each bundle the code it can act on.
*
* The body carries `meta.errorCode` and nothing else — no current epoch, no
* current scope. A client may learn its context from exactly two responses
* (`GET /api/auth/me` and `POST /api/account/switch`), and putting the truth in
* this refusal would open a third path, one that arrives on the very requests
* whose context is in doubt.
*/
export class RecordSessionChangedError extends SharingAuthError {
readonly errorCode = RECORD_FENCE_ERROR_CODE;
constructor() {
super(409, "The record this session is on has changed");
this.name = "RecordSessionChangedError";
}
}
/**
* Wraps an API route handler with Wide Event logging.
* Creates a WideEventBuilder, runs the handler inside AsyncLocalStorage,
* catches errors, and emits the event on completion.
*
* No CSRF check — HealthLog does not use CSRF tokens.
* Auth annotation happens in routes via requireAuth().
*/
// Read a property from a request-like value without invoking native
// private-field getters (NextRequest.method / .url / .headers access
// `this.#state` and crash with `Cannot read private member #state from
// an object whose class did not declare it` when the request is a
// Proxy or a synthetic placeholder — Next 16 passes such placeholders
// to `force-static` route handlers during dev). We probe defensively
// and fall back to safe defaults so logging instrumentation never
// crashes the handler.
//
// The catch is narrowed to two well-known shapes: the V8 private-field
// TypeError, and the `Cannot read properties of undefined/null` shape
// raised when the wrapper is invoked without a request (vitest tests
// frequently invoke handlers as `GET()` with no args, mirroring the
// shape Next.js exercises for the static-export pass). Any other
// exception is a real bug in the read callback or in a downstream
// header parser and must surface — swallowing it would hide
// regressions in production instrumentation.
function isTolerableRequestProbeError(err: unknown): boolean {
if (!(err instanceof TypeError)) return false;
const msg = err.message ?? "";
return (
// V8 — current
msg.includes("private member") ||
// V8 — alternative
msg.includes("private field") ||
// Bun / older V8
msg.includes("private name") ||
// No request handed in at all (vitest direct-invoke or
// force-static placeholder reduced to undefined / null).
// Covers both modern `Cannot read properties of undefined
// (reading 'X')` and the older `Cannot read property 'X' of
// undefined` wordings.
/Cannot read propert(?:y|ies)\b.*\bof (?:undefined|null)\b/.test(msg)
);
}
function safeRequestProp<R>(
request: unknown,
read: (req: NextRequest) => R,
fallback: R,
): R {
try {
return read(request as NextRequest);
} catch (err) {
if (isTolerableRequestProbeError(err)) {
// v1.4.27 B7 / BL-P1-3 — surface every fallback so a real read
// regression cannot hide behind the tolerated-error narrowing.
// Vitest direct-invoke and the force-static placeholder path are
// the two known-quiet shapes; anything else worth a look should
// show up in the dev console + the run log.
if (process.env.NODE_ENV !== "test") {
const msg = err instanceof Error ? err.message : String(err);
console.warn(
`[api-handler] safeRequestProp fallback — tolerable error: ${msg}`,
);
}
return fallback;
}
throw err;
}
}
/** @internal — exposed for unit tests of the narrow-catch contract. */
export const __testables = {
safeRequestProp,
isTolerableRequestProbeError,
};
// Next.js route handlers come in two shapes — `(request)` for static routes
// and `(request, { params })` for dynamic ones. The variadic generic is the
// only signature TS accepts that covers both at the call site. The `any[]`
// here is constrained by the bound (T must return Promise<Response>) so it
// does not loosen handler bodies — only their parameter list.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
handler: T,
): T {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const wrapped = async (...args: any[]): Promise<Response> => {
const request = args[0] as NextRequest;
const requestUrl = safeRequestProp(request, (r) => r.url, "");
const url = (() => {
try {
return new URL(requestUrl);
} catch {
// No usable URL (e.g. force-static placeholder) — fall back to
// a synthetic origin so the rest of the pipeline can still
// attach a pathname.
return new URL("http://localhost/");
}
})();
const evt = new WideEventBuilder("http");
// Propagate x-request-id if present
const incomingRequestId = safeRequestProp(
request,
(r) => r.headers.get("x-request-id"),
null,
);
if (incomingRequestId) evt.setRequestId(incomingRequestId);
evt.setHttp({
method: safeRequestProp(request, (r) => r.method, "GET"),
path: url.pathname,
route: url.pathname,
status: 200,
user_agent:
safeRequestProp(request, (r) => r.headers.get("user-agent"), null) ??
undefined,
ip:
safeRequestProp(
request,
(r) => r.headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
null,
) ||
safeRequestProp(request, (r) => r.headers.get("x-real-ip"), null) ||
undefined,
});
return eventStorage.run(evt, async () => {
let response: Response | undefined;
try {
response = await handler(...args);
} catch (error) {
if (error instanceof AssistantDisabledError) {
// v1.4.31 — operator has disabled the assistant surface.
// The 403 + `errorCode: "assistant.disabled.<surface>"`
// envelope is locked per
// `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5.
// Older iOS clients that don't know the errorCode surface
// this as a generic 403; v1.4.31+ clients can branch on the
// errorCode to render an inline operator-disabled notice.
evt.setError(error);
response = NextResponse.json(
{
data: null,
error: error.message,
meta: { errorCode: error.errorCode },
},
{ status: 403 },
);
} else if (error instanceof ConsentRequiredError) {
// v1.12.1 — server-side consent gate before external-LLM PHI
// egress on the operator's server-managed key. Mirrors the
// AssistantDisabledError envelope (403 + meta.errorCode) so the
// iOS client renders an inline "grant consent" notice instead of
// a generic failure.
evt.setError(error);
response = NextResponse.json(
{
data: null,
error: error.message,
meta: { errorCode: error.errorCode },
},
{ status: 403 },
);
} else if (error instanceof StepUpRequiredError) {
// v1.23 — step-up gate not satisfied. Same 401 + meta.errorCode
// envelope shape as the assistant/consent gates so the client can
// branch on the stable code and launch a re-verification flow.
// Checked before the generic HttpError branch because
// StepUpRequiredError extends it.
evt.setError(error);
response = NextResponse.json(
{
data: null,
error: error.message,
meta: { errorCode: error.errorCode },
},
{ status: error.statusCode },
);
} else if (error instanceof SharingAuthError) {
// v1.36.0 — the acting-account resolver refused. Same
// 403 + meta.errorCode envelope as the gates above, so a client
// distinguishes "you may not act as that account" from "this
// endpoint does not support acting on one" without parsing prose.
// Checked before the generic HttpError branch because it extends it.
evt.setError(error);
response = NextResponse.json(
{
data: null,
error: error.message,
meta: { errorCode: error.errorCode },
},
{ status: error.statusCode },
);
} else if (error instanceof HttpError) {
evt.setError(error);
response = NextResponse.json(
{ data: null, error: error.message },
{ status: error.statusCode },
);
} else if (error instanceof SyntaxError) {
evt.setError(error);
response = NextResponse.json(
{ data: null, error: "Invalid JSON body" },
{ status: 400 },
);
} else {
evt.setError(error);
// Report to GlitchTip (fire-and-forget)
reportToGlitchtip(error, request, evt).catch(() => {});
response = NextResponse.json(
{ data: null, error: "Interner Serverfehler" },
{ status: 500 },
);
}
} finally {
const status = (response as Response | undefined)?.status ?? 500;
evt.finish(status);
try {
emitIfSampled(evt.toJSON());
} catch {
/* logging must never crash the handler */
}
}
const nr = response as NextResponse;
nr.headers.set("x-request-id", evt.getRequestId());
// v1.37.0 — echo the record context this response was actually served
// under, when one was decided. The value comes from the wide event and
// from nowhere else: the fence stamps it on every call it makes, so
// exactly the responses that resolved a record scope carry it and
// everything else — public routes, actor surfaces, admin, `/me`, static —
// carries nothing.
//
// Deliberately NOT derived here with a `getSession()`. That would put a
// session read on every public route, and it would make the echo a second
// derivation of the context rather than a report of the one that was
// used, which is the difference between "what this response served" and
// "what a later read thinks is true".
//
// Absence is therefore meaningful and safe: the client discards a
// response whose echo CONTRADICTS its adopted context, and serves one
// that carries no echo normally. A discard-on-absence rule would throw
// away the `/api/version` poll on every cycle.
//
// The two header names stay inside the fence module, which is what keeps
// them to four files overall (declared, read, attached client-side,
// published) — see `src/__tests__/record-session-fence-guard.test.ts`.
attachRecordContextEcho(nr.headers, evt.getRecordContext());
return nr;
});
};
return wrapped as T;
}
/**
* Authenticated request context. Returned by both session-cookie and Bearer-token
* authentication paths. The `session.id` is the session-record id for cookie auth
* and the `ApiToken` id for bearer auth — callers must not assume the id refers
* to a Session row.
*/
export type AuthContext = {
session: {
id: string;
expiresAt: Date;
/**
* v1.36.0 — the cookie transport's acting-account carrier, straight off
* the session row. Absent on the Bearer path, which has no session row and
* carries its selector in a per-request header instead.
*
* Optional so that the ~180 test files which hand `requireAuth` a
* hand-built session context keep compiling; an absent field means the
* same thing as a null one — no switch. Only the acting-account resolver
* below reads it.
*/
readonly actingAsUserId?: string | null;
/**
* v1.37.0 — how many times this session's record selector has moved. The
* record-session fence compares it against what the request asserted.
*
* Optional for the same reason as `actingAsUserId` above, and `undefined`
* reads as `0` — which is the fence's exemption, so a hand-built test
* context keeps meaning "a session that never switched". Read only by the
* fence.
*/
readonly recordEpoch?: number;
};
user: User;
/**
* 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";
};
/**
* Require an authenticated request. Throws HttpError(401) / HttpError(403) on failure.
*
* Auth precedence (cookie-first, never both):
* 1. Valid session cookie → cookie path (existing behaviour, requiredPermission ignored).
* 2. No cookie + `Authorization: Bearer hlk_<...>` → API token path.
* 3. Neither → 401.
*
* v1.36.0 — and it REFUSES to run while the request is acting on another
* account. A bare `requireAuth()` is an undeclared route: nobody has decided
* whether it is safe under a switch, and the routes that call it include the
* password change, the MFA management surface, the token mint, and the
* integration connect. So a request carrying an acting-account carrier gets a
* 403 `sharing.not_permitted` here, and never a silent fall-back to the
* caller's own record.
*
* The fall-back is the tempting wrong answer and is worth naming: it looks
* harmless, it keeps every un-migrated route working, and it is the reverse
* data-mixing failure. A delegate reading what they believe is the owner's
* record would be shown their own; a delegate writing what they believe is the
* owner's measurement would write it into theirs. An inaccessible route is an
* inconvenience. A route that answers with the wrong person's health record is
* the product being wrong about the only thing it does.
*
* @param requiredPermission Optional permission scope. Only enforced for Bearer
* auth — cookie sessions always pass (full user access). Omitting it is a
* positive declaration: the route accepts cookie sessions and cookie-
* equivalent (`["*"]`) tokens ONLY, and refuses a narrow-scope token with
* HttpError(403). Naming a scope additionally admits narrow tokens that list
* it. A token that lists neither `*` nor the named scope throws
* HttpError(403).
*/
export async function requireAuth(
requiredPermission?: string,
): Promise<AuthContext> {
const auth = await authenticateCaller(requiredPermission);
const carrier = await readActingCarrier(auth);
if (carrier.kind !== "none") {
throw refuseUndeclaredMode(auth, carrier);
}
return auth;
}
/**
* Resolve the caller as themselves. The pre-v1.36.0 `requireAuth` body,
* unchanged, split out so the three declared modes above it can share it.
*
* This function knows nothing about acting accounts and must not learn: it is
* the thing every mode agrees on before they disagree about what the request
* is allowed to reach.
*/
async function authenticateCaller(
requiredPermission?: string,
): Promise<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");
}
/**
* Authenticate a raw Bearer token against `ApiToken`.
*
* The validation itself lives in the transport-agnostic `resolveBearerToken`
* (`./auth/bearer`) — the single source of truth shared with the MCP wire. This
* wrapper adds the HTTP-edge concerns: the `auth.bearer.failure` audit trail
* (only the failure path writes a durable audit row — the success row was
* intentionally dropped for perf) and the Wide-Event `auth_method: "bearer"`
* annotation, then maps the result onto the `AuthContext` contract
* (`session.id` carries the token id).
*
* Authorisation contract (fail-closed): a route that declares no
* `requiredPermission` accepts cookie sessions and cookie-equivalent (`["*"]`)
* tokens only — a narrow-scope token is refused 403 with an `undeclared_scope`
* audit row. A route that declares a scope additionally accepts narrow tokens
* that list it. The absence of an argument is a positive statement, not an
* omission, which is what makes the default safe for routes nobody has thought
* about yet.
*/
async function authenticateBearer(
rawToken: string,
requiredPermission: string | undefined,
): Promise<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(() => {});
const message =
err.statusCode === 403
? "Insufficient permissions"
: err.reason === "expired"
? "Token expired"
: "Invalid token";
throw new HttpError(err.statusCode, message);
}
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");
}
}
// v1.25 — no per-request success audit row. The polling iOS client drove a
// constant INSERT + pool checkout on every authenticated Bearer request; the
// wide event below already records `auth_method: "bearer"` + `user_id`, so the
// success path stays fully observable without the write churn. The failure
// path keeps its audit row.
const evt = getEvent();
if (evt) {
evt.setAuth({
user_id: user.id,
user_role: user.role,
auth_method: "bearer",
});
}
return {
session: { id: tokenId, expiresAt },
user,
authMethod: "bearer",
};
}
// ── The acting-account resolver ─────────────────────────────────────────────
//
// One function decides, for every transport, whether this request acts on
// somebody else's record — the same argument that put `resolveBearerToken` in
// one file for two wires. Two carriers converge on one decision because they
// are two answers to one question, and two answers that live apart drift.
//
// That decision now lives in `./auth/acting-carrier`, because a second caller
// needs it: the idempotency wrapper files its replay cell under the record a
// request claims, and it runs before any auth context exists. What stays here
// is what only this file may do — turning a claim into a data scope after a
// live grant says so.
/**
* The selector header's name, re-exported.
*
* 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 { ACCOUNT_SELECTOR_HEADER } from "./auth/acting-carrier";
/**
* The acting-account carrier for this request.
*
* The session value rides out of `getSession()` on the row that call already
* loaded — a second query for a column we have just read would be waste on
* every cookie request in the app. What does NOT ride out of `getSession()` is
* a substituted user: it keeps answering "who is calling" and nothing else,
* which is exactly why `requireAdmin`, `requireCookieAuth` and
* `requireFreshMfa` are unreachable by a switch. The carrier is an id to be
* checked, not a decision that has been made.
*
* Fresh on every request, by construction: `getSession()` reads the row from
* Postgres each time, so a switch cleared by a revocation is gone from the very
* next request rather than from the next login.
*/
async function readActingCarrier(auth: AuthContext): Promise<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.
*/
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.
*
* Two ways to arrive: a route that never declared a mode (`undeclared_mode`),
* and a selector sent over the transport that does not carry one
* (`misplaced_selector`). One response either way — the caller learns that the
* request will not be served, and nothing about grants.
*
* The audit rule, stated once because it is a judgement call: a HEADER refusal
* always writes a row, a SESSION refusal never does. The header is a per-request
* assertion by a client — a bug or a probe, and both are worth a durable record.
* The session field is ambient browser state: while a switch is on, every
* navigation, prefetch and poll that touches a non-delegable route lands here,
* and a row per such request would bury the header rows that matter under noise
* from the feature working as designed. Both are on the wide event either way.
*/
function refuseUndeclaredMode(
auth: AuthContext,
carrier: ActingCarrier,
reason: "undeclared_mode" | "misplaced_selector" = "undeclared_mode",
): SharingNotPermittedError {
annotate({ meta: { sharing_refusal: reason } });
if (carrier.kind !== "session") {
auditRefusal(auth, carrier, reason);
}
return new SharingNotPermittedError();
}
/**
* v1.36.0 — an actor surface: this route always serves the CALLER's own rows.
*
* It works under a switch. That is the whole reason the mode exists: the
* switcher itself, the banner, and the "which records may I open" list all have
* to keep answering while the browser is acting as someone else, and every one
* of them is about the delegate. So the session carrier is read and
* deliberately ignored — no query, because there is nothing the answer could
* change.
*
* A selector HEADER is refused, loudly. An actor surface is DEFINED as serving
* the caller's own rows, so a client that attaches a selector to one has a bug,
* and the bug is invisible until somebody files a support ticket about data
* that looks wrong. Ignoring the header would serve exactly the right response
* to exactly the wrong expectation.
*/
export async function requireActorAuth(): Promise<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();
}
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: User;
/** The grant being exercised, or null when the caller acts as themselves. */
readonly grantId: string | null;
}
/**
* v1.36.0 — a delegable surface: this route may act on a shared record.
*
* Calling it is a DECLARATION, reviewed and frozen by a structural guard, that
* the route reads or writes health data and nothing else — no credential, no
* integration, no notification channel, no grant management, nothing that would
* let a delegate extend their own reach. The declaration is the security
* property; this function only enforces what was declared.
*
* @param need what the route does to the record. `"read"` still refuses a
* non-safe HTTP method under a read-only grant: the declaration and the
* method must BOTH be satisfiable, so a delegable GET handler that grows a
* POST export cannot quietly inherit the read grant's permission. An unknown
* method (no event context) counts as a write and is refused, the same
* fail-closed posture as the MCP audience guard above. A route declaring
* `"manage"` refuses everything below it regardless of method: the method
* can only escalate what the declaration asked for, never satisfy it.
* @param domain v1.37.0 — which section of the record the route touches, or
* `"record"` when it reads across sections. Required, and the requirement is
* the fail-closed lever: a delegable route without a classification does not
* typecheck, so the set of routes carrying one cannot fall behind the set of
* routes that are delegable. The value is frozen per module by
* `src/__tests__/sharing-surface-guard.test.ts` and reviewed against the
* design's clustering table; this function only enforces what was declared.
*/
export async function requireRecordAuth(
need: GrantNeed,
domain: ShareScope,
): Promise<RecordAuthContext> {
const auth = await authenticateCaller();
// v1.37.0 — before any carrier is read, any grant is looked up, or any record
// row is touched: does this request still believe what its session believes?
//
// The atomicity that makes this a tautology rather than a race: the epoch the
// fence compares and the `actingAsUserId` the carrier below is built from are
// BOTH projected off the one session row `authenticateCaller()` already
// loaded. The scope this handler serves under and the epoch it validated are
// therefore the same fact read at the same instant, not two reads a commit
// can land between. Do not add a second session read here to "refresh" it —
// that would reintroduce exactly the window this ordering removes.
//
// `authenticateCaller` necessarily runs first because it supplies the row the
// fence reads, so the invariant is "before the carrier", not "before the
// first database await".
await assertRecordSessionFence(auth);
const carrier = await readActingCarrier(auth);
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 effectiveNeed: GrantNeed =
need !== "read" || !READ_HTTP_METHODS.has(method) ? escalate(need) : "read";
return resolveSwitchedRecord(auth, carrier, (grant) => {
if (!grantAllows(grant, effectiveNeed)) return "insufficient_access";
// Level first, then scope, and the order is visible on the wire in exactly
// one way: not at all. Both refusals are the same error with the same
// bytes, and only the audit reason differs — so the ordering is a choice
// about what the operator's trail says happened, not about what the caller
// learns. It says the bigger thing: a delegate who was never given this
// level is a different story from one whose sections do not reach here.
if (!grantCoversDomain(grant, domain)) return "out_of_scope";
return null;
});
}
/**
* The need a non-safe method implies, for a route that declared `need`.
*
* A PUT on a route declaring `"read"` needs a write grant, which is what this
* has always done. A PUT on a route declaring `"manage"` needs a MANAGE grant
* and must not be reduced to a write — so the escalation raises the floor to
* `"write"` and never lowers a declaration that already sits above it.
*/
function escalate(need: GrantNeed): GrantNeed {
return need === "manage" ? "manage" : "write";
}
/**
* v1.37.0 — a guardian surface: this route administers a managed profile.
*
* The other declaration, and a separate function rather than a flag on the one
* above, for the reason `requireActorAuth` is also separate: the guard freezes
* each declaration as its own list, so a route moving between them is a diff a
* human reviews rather than an argument about a parameter.
*
* What it admits is narrower than it looks. An active MANAGE grant is
* necessary and NOT sufficient — the record must also carry the managed-profile
* marker. That second condition is the identity fence, and it is the line
* between "manage my data" and "own my account": an adult who granted
* management of their own record reaches none of this, at any level, by any
* argument, because their record has no marker and never will while they hold
* it. A managed profile has no self to reserve these surfaces for, so its
* guardian holds them.
*
* The fence is gated on the marker rather than on the grant precisely so that
* no future change to what MANAGE means can reach through it. Widening MANAGE
* would widen `requireRecordAuth`; it would not touch this function.
*/
export async function requireGuardianAuth(): Promise<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);
const carrier = await readActingCarrier(auth);
if (carrier.kind === "none") {
// The owner of an ordinary record, reaching their own settings. Same
// do-no-harm posture as the record resolver: without a carrier this is the
// request it always was.
return { ...auth, actor: auth.user, grantId: null };
}
if (carrier.kind === "misplaced-header") {
throw refuseUndeclaredMode(auth, carrier, "misplaced_selector");
}
return resolveSwitchedRecord(auth, carrier, (grant, owner) => {
if (!grantAllows(grant, "manage")) return "insufficient_access";
if (owner.managedProfileAt === null) return "guardian_only";
return null;
});
}
/**
* The shared body of both record resolvers: turn a selector into an owner, or
* refuse.
*
* Everything either resolver does with a carrier lives here except the
* admission rule itself, which arrives as `admit` — a function of the grant
* and the owner returning an audit reason to refuse with, or null to proceed.
* The split is where it is because the two resolvers differ in exactly that
* one decision and in nothing else, and two copies of the grant lookup, the
* refusal shape, the usage stamp and the owner's access row would be two
* copies to keep in step.
*
* The owner row is loaded BEFORE the admission runs, which is a change from
* v1.36.0's ordering and a deliberate one: the guardian rule is a fact about
* the owner, so it cannot be decided before the owner is known. Nothing about
* the refusal moved — every arm below is the same error with the same bytes,
* and an admission that refuses after the load costs one query it does not use
* and tells the caller nothing extra.
*/
async function resolveSwitchedRecord(
auth: AuthContext,
carrier: Extract<ActingCarrier, { accountId: string }>,
admit: (grant: AccountGrant, owner: User) => 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 },
});
// Unreachable while the FK cascade holds — a grant naming a deleted account
// is deleted with it. Fail closed anyway: an authorization we cannot resolve
// to a person is not one to act on.
if (!owner) throw denied("owner_missing");
const refusal = admit(grant, owner);
if (refusal !== null) throw denied(refusal);
getEvent()?.setActingAs(owner.id);
// v1.37.0 — and whether this request may spend the owner's provider budget.
// MANAGE opens the generated reads; it does not open the generation behind
// them, because the owner would pay for and consent to an egress they did
// not cause. A managed profile is the exception and the reason the marker is
// read here rather than the grant: it has no self to protect from its own
// guardian. Stamped where the owner row is already loaded, so no generator
// has to be told; see `src/lib/sharing/delegated-generation.ts`.
if (owner.id !== auth.user.id && owner.managedProfileAt === null) {
getEvent()?.setDelegatedGenerationSuppressed();
}
getEvent()?.setProviderWorkAuthority({
origin: owner.managedProfileAt === null ? "delegate" : "guardian",
recordUserId: owner.id,
actorUserId: auth.user.id,
grantId: grant.id,
});
// Fire-and-forget, the `ApiToken.lastUsedAt` posture: the read must not wait
// on the bookkeeping, and the bookkeeping failing must not fail the read.
void touchGrantUsage(grant.id);
// The owner's copy of the same fact, and the one they can read: a day-
// coalesced row on their own record saying who opened it. It lands here
// rather than in the handlers because a route cannot forget what it never
// had to remember — every delegated request in the product passes through
// this line, and only requests that got past the grant check reach it.
void recordDelegatedAccess(owner.id, auth.user.id);
return {
session: auth.session,
user: owner,
authMethod: auth.authMethod,
actor: auth.user,
grantId: grant.id,
};
}
/**
* Require an authenticated admin user. Throws HttpError(401) or HttpError(403).
* Cookie-only — Bearer tokens never elevate to admin (security boundary).
* Automatically annotates the Wide Event with auth context.
*
* v1.36.0 — unreachable by a switch, structurally: it resolves through
* `getSession()`, which knows only who is calling, and the role is read off
* that row. Switching into an ADMIN's record therefore confers nothing, and
* making it confer something would take a deliberate edit here rather than an
* oversight elsewhere.
*/
export async function requireAdmin(): Promise<AuthContext> {
const sessionData = await getSession();
if (!sessionData) throw new HttpError(401, "Not authenticated");
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");
}
return { ...sessionData, authMethod: "cookie" };
}
/**
* v1.23 — require a cookie-backed session, refusing Bearer tokens.
*
* Cookie-only by the same structural argument as `requireAdmin`: it resolves
* the session via `getSession()` (which reads only the session cookie) and
* never falls through to the Bearer branch. The second-factor management
* surfaces (TOTP enroll / confirm / disable / recovery-code regenerate) use
* this so an API token — even a wildcard one — can never enrol or tear down
* MFA on the account it belongs to. MFA management is a browser-only action.
*/
export async function requireCookieAuth(): Promise<AuthContext> {
const sessionData = await getSession();
if (!sessionData) throw new HttpError(401, "Not authenticated");
const evt = getEvent();
if (evt) {
evt.setAuth({
user_id: sessionData.user.id,
user_role: sessionData.user.role,
auth_method: "session",
});
}
return { ...sessionData, authMethod: "cookie" };
}
/**
* Error thrown when a step-up gate is not satisfied. Carries `errorCode` so
* the route can surface a stable machine code (`auth.stepup.required`) the
* client branches on to launch a re-verification flow rather than parsing
* prose.
*/
export class StepUpRequiredError extends HttpError {
constructor(
public errorCode: string = "auth.stepup.required",
message = "Recent second-factor verification required",
) {
super(401, message);
this.name = "StepUpRequiredError";
}
}
/**
* Default step-up freshness window (5 minutes) for sensitive mutations.
* Within the 5–15 min band OWASP recommends; tight end because the gated
* actions (disable MFA, regenerate codes, and later key rotation / export)
* are destructive.
*/
export const MFA_STEP_UP_MAX_AGE_SECONDS = 5 * 60;
export type FreshMfaContext = AuthContext & { mfaVerifiedAt: Date };
/**
* v1.23 — step-up gate. Passes only for a COOKIE session whose
* `Session.mfaVerifiedAt` is within `maxAgeSeconds` AND whose user has an
* active second factor (`totpConfirmedAt`). 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.
*/
export async function requireFreshMfa(
maxAgeSeconds: number,
): Promise<FreshMfaContext> {
const sessionData = await getSession();
if (!sessionData) throw new HttpError(401, "Not authenticated");
const evt = getEvent();
if (evt) {
evt.setAuth({
user_id: sessionData.user.id,
user_role: sessionData.user.role,
auth_method: "session",
});
}
// The user must actually have a second factor active. A single-factor
// account cannot produce a fresh-MFA proof, so step-up-gated actions are
// unreachable for it by design (the management UI gates enrolment first).
// Either factor counts: a confirmed TOTP secret OR a registered WebAuthn
// security key — both stamp `Session.mfaVerifiedAt` on a completed login.
if (!sessionData.user.totpConfirmedAt) {
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
where: { userId: sessionData.user.id },
});
if (webauthnKeyCount === 0) {
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) {
throw new StepUpRequiredError();
}
return { ...sessionData, authMethod: "cookie", mfaVerifiedAt: verifiedAt };
}
/**
* v1.23 — conditional step-up for destructive account actions.
*
* Resolves the caller with the standard `requireAuth()` (cookie OR Bearer).
* For an account WITHOUT a confirmed second factor the caller passes straight
* through — a single-factor user is intentionally unaffected, so account
* deletion / data reset keeps its existing typed-confirmation-only contract.
* For an account WITH MFA active (`totpConfirmedAt` set) it additionally runs
* `requireFreshMfa`, which is cookie-only by construction: an MFA-enrolled
* account's Bearer transport carries no `mfaVerifiedAt` and therefore cannot
* satisfy step-up, surfacing `StepUpRequiredError` (401,
* `errorCode: "auth.stepup.required"`) so the UI launches a re-verification.
*
* Gating only the MFA-enrolled cohort keeps the boundary structural: a
* hijacked live cookie session for an MFA user cannot nuke the record without
* a fresh factor, while users who never opted into MFA are not forced through
* a flow they have no way to complete.
*/
export async function requireFreshMfaIfEnrolled(
maxAgeSeconds: number,
): Promise<AuthContext> {
const auth = await requireAuth();
// Either factor enrols the account: a confirmed TOTP secret OR a registered
// WebAuthn security key. A webauthn-only user must clear step-up too, so the
// destructive-action boundary tracks `requireFreshMfa`'s either-factor rule.
let enrolled = Boolean(auth.user.totpConfirmedAt);
if (!enrolled) {
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
where: { userId: auth.user.id },
});
enrolled = webauthnKeyCount > 0;
}
if (enrolled) {
await requireFreshMfa(maxAgeSeconds);
}
return auth;
}
/**
* A Bearer-resolved caller.
*
* `apiTokenId` and `accessTokenHash` are named rather than smuggled through
* `AuthContext.session.id`. That field means "the session row id" on the cookie
* path and "the ApiToken row id" on the Bearer path, and code that forgot which
* one it held passed a token id to a session-scoped query — deleting every one
* of the user's browser sessions and revoking the caller's own refresh token.
* Naming the fields is what stops that recurring.
*/
export interface BearerAuthContext extends AuthContext {
/** The `ApiToken` row id — the binding a step-up elevation is tied to. */
apiTokenId: string;
/**
* HMAC of the presented access token, which is what `RefreshToken`
* cross-references in `accessTokenHash`. Lets a route identify the CALLER's
* own device login and spare it when revoking every other one.
*/
accessTokenHash: string;
}
/**
* v1.30.34 — resolve a caller by Bearer token ONLY, refusing a cookie session.
*
* The mirror image of `requireCookieAuth`, and it exists for one surface: the
* step-up mint endpoints, which are the Bearer transport's own re-authentication
* flow and have no meaning for a browser (a browser re-proves a factor at login
* and carries the result on its session row). Refusing the cookie keeps the mint
* surface entirely outside the cookie's blast radius — no ambient credential can
* reach it, so the class of attack where a browser is induced to fire a request
* on the user's behalf simply does not apply.
*
* Scope handling is the standard fail-closed default: no declared scope, so only
* a cookie-equivalent (`["*"]`) token is admitted. A narrow token — an MCP grant,
* a medication-ingest grant — is refused 403 by the resolver.
*/
export async function requireBearerAuth(): Promise<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");
}
const raw = authHeader.slice(7);
const auth = await authenticateBearer(raw, undefined);
return {
...auth,
apiTokenId: auth.session.id,
accessTokenHash: hashToken(raw),
};
}
/**
* Header carrying a step-up elevation on the Bearer path. Never logged — the
* wide event captures no request headers at all, and `redactSecrets` carries an
* `hle_` rule as a second line of defence.
*/
export const STEP_UP_ELEVATION_HEADER = "x-step-up";
/**
* What an MFA-management route received, with the transport made explicit.
*
* The two arms carry DIFFERENT fields on purpose. A cookie caller has a session
* row and a Bearer caller does not, so `session` exists only on the cookie arm
* and the compiler refuses to read it on the other. This is the fix for a real
* defect: the MFA-disable route passed `session.id` to `destroyOtherSessions`,
* which on the Bearer path was an ApiToken id — matching no session row, so the
* "keep the current one" exclusion excluded nothing and the caller revoked its
* own device login. A comment would not have caught that; a type does.
*/
export type MfaManagementContext = {
user: User;
/**
* 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 }
);
/**
* v1.30.34 — the single gate for second-factor management.
*
* Every MFA-management MUTATION goes through here and nothing else does. The set
* is frozen by `src/__tests__/step-up-elevation-guard.test.ts`, so a future
* route cannot quietly join it: widening the reach of an elevation has to be a
* visible edit to that allowlist.
*
* Two accepted proofs, and they are equals rather than a primary and a fallback:
*
* COOKIE — unchanged, byte for byte. A cookie session delegates to
* `requireCookieAuth` / `requireFreshMfa` exactly as before this function
* existed. The web flow cannot regress here because there is no new code on
* its path; the elevation branch is only reached when there is no session at
* all.
*
* BEARER + ELEVATION — a token that resolves cleanly AND presents a valid,
* unconsumed elevation minted for that same token against a re-proved factor
* of sufficient strength. The token alone is never enough.
*
* FRESH FACTOR IS ABOUT WHICH FACTOR, NOT JUST HOW RECENT. On the cookie path
* `requireFreshMfa` reads `Session.mfaVerifiedAt`, and only a completed second
* factor or a primary passkey login ever writes it — a password login does not.
* The Bearer arm holds the identical line through `FRESH_FACTOR_METHODS`: a
* password-proved elevation reaches what a plain cookie session reaches and
* stops there. Without that rule, a stolen token plus the account password could
* rotate the recovery codes and spend one to disable the second factor.
*
* `requireAdmin` is untouched and stays cookie-only. An elevation cannot reach
* it — not because a check refuses one, but because `requireAdmin` resolves
* through `getSession()` and never consults this function or the header.
*
* @param options.freshFactor mirrors the cookie path's `requireFreshMfa`. Set by
* the destructive routes (disable, recovery-code rotation, security-key
* removal).
*/
export async function requireMfaManagementAuth(
options: { freshFactor?: boolean } = {},
): Promise<MfaManagementContext> {
const freshFactor = options.freshFactor === true;
// 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)
: 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();
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 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: raw,
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 with no
// second factor enrolled, because a step-up-gated action is meaningless there.
// The Bearer path holds the same line rather than becoming the softer route.
if (freshFactor) {
let enrolled = Boolean(auth.user.totpConfirmedAt);
if (!enrolled) {
const keys = await prisma.webauthnMfaCredential.count({
where: { userId: auth.user.id },
});
enrolled = keys > 0;
}
if (!enrolled) {
throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled");
}
}
return {
transport: "bearer",
user: auth.user,
apiTokenId: auth.apiTokenId,
accessTokenHash: auth.accessTokenHash,
commitElevation: async () => {
const claimed = await claimStepUpElevation({
rawToken: raw,
userId: auth.user.id,
apiTokenId: auth.apiTokenId,
requireFreshFactor: freshFactor,
});
if (!claimed.ok) throw refusal(claimed.reason);
annotate({
action: { name: "auth.stepup.elevation.accepted" },
meta: { method: claimed.method, freshFactor },
});
},
};
}
/**
* Report unhandled errors to GlitchTip (fire-and-forget).
* Uses dynamic import to avoid circular dependencies and startup cost.
*/
async function reportToGlitchtip(
error: unknown,
request: NextRequest,
evt: WideEventBuilder,
): Promise<void> {
const [{ getGlitchtipSettings }, { sendGlitchtipEvent }] = await Promise.all([
import("@/lib/monitoring-settings"),
import("@/lib/monitoring/glitchtip"),
]);
const settings = await getGlitchtipSettings();
if (!settings.glitchtipEnabled || !settings.glitchtipDsn) return;
const err =
error instanceof Error
? error
: new Error(typeof error === "string" ? error : "Unknown error");
// Skip expected errors from bot scanners (malformed JSON bodies)
if (err instanceof SyntaxError) return;
// Audit H-B7 / phase P2: strip the query string before forwarding to
// GlitchTip. Withings legacy callbacks ship `?secret=…` (see C-3) and
// OAuth callbacks ship `?code=…&state=…`; if any of those error we
// don't want their secrets in someone's incident UI.
const rawUrl = safeRequestProp(request, (r) => r.url, "");
let scrubbedUrl = rawUrl;
try {
const u = new URL(rawUrl);
u.search = "";
scrubbedUrl = u.toString();
} catch {
// Invalid URL — fall through with the raw value (only happens in
// degenerate test fixtures).
}
await sendGlitchtipEvent({
dsn: settings.glitchtipDsn,
input: {
environment: settings.glitchtipEnvironment || "production",
// Defence in depth: even though the WideEventBuilder already
// redacts on `setError()`, the GlitchTip path imports `err`
// directly. Apply the same redaction here so a Telegram bot
// token or external Bearer cannot leak via the incident UI.
message: redactSecrets(err.message),
level: "error",
type: err.name || "Error",
stack: redactOptional(err.stack),
// Query string is already stripped above, but path-segment secrets
// (e.g. `/api/withings/webhook/<secret>`, `/api/whoop/webhook/<secret>`)
// survive that strip. Run the same `redactSecrets` pass that guards the
// message/stack so a `PATH_SECRET_PATHS`-registered secret cannot reach
// the external incident UI.
url: redactSecrets(scrubbedUrl),
sourceTag: "healthlog-api-handler",
requestId: evt.getRequestId(),
},
});
}