Files
HealthLog/src/lib/api-handler.ts
T
Marc-André Bombeck f24ae6deb8 Read the acting-account carrier from one place
The rule that decides what a request says it is acting as — session stamp on
the cookie transport, selector header on Bearer, a header on a cookie request
naming nothing — sat inside the resolver that acts on the answer. A second
caller needs the same question answered before any auth context exists, and
two implementations of "whose record is this" drift; the copy that drifts is
the one nobody reads.

Move the decision, the header name and the two predicates into
`auth/acting-carrier`, as a pure function of what the transport carried. The
resolver keeps what only it may do: turning a claim into a data scope once a
live grant says so. No behaviour changes — the resolver's arms are the same
arms, and its suite passes untouched.

The header-name guard follows the constant to its new file.
2026-08-03 10:13:55 +02:00

1311 lines
52 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 { 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,
touchGrantUsage,
type GrantNeed,
} from "./sharing/grants";
/**
* 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";
}
}
/**
* 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());
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;
};
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.
*
* Written for the OWNER's benefit as much as the operator's: "someone tried to
* open my record and was turned away" is the question the sharing panel exists
* to answer. The row names the actor (the caller), the account they aimed at,
* and why it failed — none of which reaches the response.
*
* 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.
*/
export async function requireRecordAuth(
need: GrantNeed,
): Promise<RecordAuthContext> {
const auth = await authenticateCaller();
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.
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");
}
/** 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");
}
const method = (getEvent()?.getHttpMethod() ?? "").toUpperCase();
const effectiveNeed: GrantNeed =
need === "write" || !READ_HTTP_METHODS.has(method) ? "write" : "read";
// 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");
if (!grantAllows(grant, effectiveNeed)) throw denied("insufficient_access");
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");
getEvent()?.setActingAs(owner.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(),
},
});
}