From a4d27c618ecb25e8ca2c64818ea11492f54e7ab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 22:21:43 +0200 Subject: [PATCH] fix(export): every whole-record export asks for a fresh proof GET /api/export with type=all (the default), POST /api/export/health-record and GET /api/fhir/Patient/$everything hand over the whole record but took a live session alone, so a stolen browser session could still walk off with everything the full-backup gate was meant to protect. A browser session now needs a sign-in or re-proof within five minutes, with the second factor on an account that has one, and the doctor-report panel opens the re-proof dialog and retries. When a manager builds the report for a record they manage, their own session is the one asked. On a token the shipped app keeps working: health-record and $everything take the token as before, including a fhir:read token, and the recent-proof guard names both. GET /api/export?type=all, which no shipped client calls with a token, wants a step-up elevation there. Single-type exports are unchanged. The API reference now also describes the enrollment rule for accounts with a second factor and the passkey refusal it adds. --- docs/api/openapi.yaml | 71 ++++++++---- .../recent-proof-surface-guard.test.ts | 27 ++++- .../__tests__/soft-delete-filter.test.ts | 39 +++++++ .../health-record/__tests__/route.test.ts | 26 +++++ src/app/api/export/health-record/route.ts | 18 ++- src/app/api/export/route.ts | 22 +++- src/app/api/fhir/Patient/$everything/route.ts | 20 +++- .../capability-and-everything.test.ts | 27 +++++ .../api/fhir/__tests__/module-gate.test.ts | 11 ++ .../report-selection/selection-panel.tsx | 104 +++++++++++------- src/lib/openapi/routes/auth.ts | 9 +- src/lib/openapi/routes/export.ts | 2 +- src/lib/openapi/routes/health-record.ts | 4 +- 13 files changed, 304 insertions(+), 76 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5953a81d8..0b8a74389 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -864,7 +864,11 @@ paths: `selection` is REQUIRED and carries the leaf inclusion list — membership is inclusion, absence is exclusion, and there is no server-side default. An unknown leaf id is a 422 that names it; the retired grouped `sections` shape is a 422 too, because accepting it would mean choosing a scope the caller did not express. Auth via cookie or - Bearer; shared `export:` rate bucket (10/h). Strict validation: unknown keys 422." + Bearer; shared `export:` rate bucket (10/h). Strict validation: unknown keys 422. A browser session + needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes + (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with + `meta.methods` naming the proofs the account can give. When a manager exports a record they manage, it is their + own session that is asked. A Bearer caller is not asked for more than its token." requestBody: required: true content: @@ -1489,7 +1493,10 @@ paths: Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport (present when the window holds a vital sign) — flattened into one `searchset` Bundle. Scoped to the owner's SAVED report selection; an account that never saved one gets nothing. Auth: `fhir:read` scope. Offset paging via `_count` - (≤200) / `_offset`." + (≤200) / `_offset`. A browser session needs a fresh proof as well as a live one: a sign-in or `POST + /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it + the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. A token, + including a `fhir:read` one, is not asked for more." parameters: - in: query name: _count @@ -1524,7 +1531,13 @@ paths: notes ride their exported column. Shares the `export:` bucket with the encrypted variant (10 per hour) and audits every attempt as `export.download`. Cookie or Bearer auth; the caller is always resolved as themselves, so this download cannot be delegated to a shared record. Not module-gated: a disabled module's rows - are still exported, because this is the account's own data leaving on request." + are still exported, because this is the account's own data leaving on request. `type=all`, the default, is the + whole record and asks for a fresh proof like the full backup; the single-type exports do not. A browser session + needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes + (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with + `meta.methods` naming the proofs the account can give. A Bearer caller asking for `type=all` presents an + `X-Step-Up` elevation from `POST /api/auth/step-up` (a second-factor one on an account with a second factor) and + receives 401 `auth.stepup.required` without it." parameters: - in: query name: format @@ -2172,14 +2185,18 @@ paths: /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused. - Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof (a - password-proved one is enough). On the cookie path the request passes when the session signed in, or completed a - second factor, within the last five minutes; otherwise the body must carry a proof in the shape POST - /api/auth/step-up takes (`password`, a `totp` code from an already confirmed authenticator, or a passkey / - security-key assertion begun at POST /api/auth/passkey/register-options). Without one the answer is 401 - `auth.reproof.required`; a proof that does not verify is 401 `auth.reproof.failed` and is audited. Proofs draw - on the account's shared re-proof budget of five per fifteen minutes (429 once spent), the same budget as the - step-up mint. + Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof: a + password-proved one is enough on an account without a second factor, and on an account that already has one the + elevation must come from that factor or a passkey. On the cookie path, for an account without a second factor, + the request passes when the session signed in, or completed a passkey sign-in, within the last five minutes; + otherwise the body must carry a proof in the shape POST /api/auth/step-up takes (`password`, or a passkey + assertion begun at POST /api/auth/passkey/register-options). For an account that already has a second factor, + only that factor counts: a second factor completed within the last five minutes, or a `totp` code, security-key + or passkey assertion in the body. A password or a young session (for example one that skipped the factor on a + remembered browser) is not enough there. Without one the answer is 401 `auth.reproof.required` with + `meta.methods` naming the proofs the account can give; a proof that does not verify is 401 `auth.reproof.failed` + and is audited. Proofs draw on the account's shared re-proof budget of five per fifteen minutes (429 once + spent), the same budget as the step-up mint. requestBody: required: false content: @@ -2316,14 +2333,18 @@ paths: refused. - Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof (a - password-proved one is enough). On the cookie path the request passes when the session signed in, or completed a - second factor, within the last five minutes; otherwise the body must carry a proof in the shape POST - /api/auth/step-up takes (`password`, a `totp` code from an already confirmed authenticator, or a passkey / - security-key assertion begun at POST /api/auth/passkey/register-options). Without one the answer is 401 - `auth.reproof.required`; a proof that does not verify is 401 `auth.reproof.failed` and is audited. Proofs draw - on the account's shared re-proof budget of five per fifteen minutes (429 once spent), the same budget as the - step-up mint. + Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof: a + password-proved one is enough on an account without a second factor, and on an account that already has one the + elevation must come from that factor or a passkey. On the cookie path, for an account without a second factor, + the request passes when the session signed in, or completed a passkey sign-in, within the last five minutes; + otherwise the body must carry a proof in the shape POST /api/auth/step-up takes (`password`, or a passkey + assertion begun at POST /api/auth/passkey/register-options). For an account that already has a second factor, + only that factor counts: a second factor completed within the last five minutes, or a `totp` code, security-key + or passkey assertion in the body. A password or a young session (for example one that skipped the factor on a + remembered browser) is not enough there. Without one the answer is 401 `auth.reproof.required` with + `meta.methods` naming the proofs the account can give; a proof that does not verify is 401 `auth.reproof.failed` + and is audited. Proofs draw on the account's shared re-proof budget of five per fifteen minutes (429 once + spent), the same budget as the step-up mint. requestBody: required: false content: @@ -3453,7 +3474,9 @@ paths: **Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options - issued. + issued. On an account that already has a second factor a password is refused before it is checked, with 422 + `auth.reproof.too_weak` and `meta.methods` naming the proofs that work: a passkey added on the password alone + would sign in without the second factor from then on. Which factor you prove changes the session, and the direction is not the obvious one: a strong proof stamps the @@ -3500,7 +3523,13 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a4 + "422": + description: "`auth.reproof.too_weak`: a password on an account that already has a second factor. `meta.methods` names + the proofs that would be accepted. Refused before the password is checked, so it spends no attempt." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/auth/passkey/register-verify: post: diff --git a/src/__tests__/recent-proof-surface-guard.test.ts b/src/__tests__/recent-proof-surface-guard.test.ts index 26fce210d..9a1e0f8ae 100644 --- a/src/__tests__/recent-proof-surface-guard.test.ts +++ b/src/__tests__/recent-proof-surface-guard.test.ts @@ -73,6 +73,8 @@ function callers(exportName: string): string[] { /** Routes that resolve their own caller and take the recent-proof gate. */ const RECENT_PROOF_ROUTES: Record = { + // `type=all` only; the single-type exports stay on `requireAuth`. + "app/api/export/route.ts": "elevation", "app/api/export/full-backup/route.ts": "elevation", "app/api/export/encrypted/route.ts": "elevation-if-enrolled", // The shipped iOS app calls these two on its token without an elevation and @@ -82,6 +84,17 @@ const RECENT_PROOF_ROUTES: Record = { "app/api/mcp/tokens/route.ts": "token", }; +/** + * Routes that resolve a cookie OR a token through their own resolver + * (`requireAuth` with a scope, `requireRecordAuth`) and add the cookie arm + * only. Their Bearer arm is the token, as with the `token` rule above: the + * shipped app calls both with its token and no elevation. + */ +const TOKEN_ARM_COOKIE_PROOF_ROUTES = [ + "app/api/export/health-record/route.ts", + "app/api/fhir/Patient/$everything/route.ts", +].sort(); + /** Cookie-only routes that add the gate after their own resolver. */ const COOKIE_PROOF_ROUTES = [ "app/api/admin/backups/[id]/download/route.ts", @@ -112,10 +125,20 @@ describe("the recent-proof gate", () => { expect(found).toEqual(RECENT_PROOF_ROUTES); }); - it("the cookie arm is added by exactly the known cookie-only routes", () => { + it("the cookie arm is added by exactly the known routes", () => { const found = callers("assertRecentCookieProof"); expect(found.length).toBeGreaterThan(0); - expect(found).toEqual(COOKIE_PROOF_ROUTES); + expect(found).toEqual( + [...COOKIE_PROOF_ROUTES, ...TOKEN_ARM_COOKIE_PROOF_ROUTES].sort(), + ); + }); + + it("a route that resolves either transport asks only on the cookie", () => { + for (const rel of TOKEN_ARM_COOKIE_PROOF_ROUTES) { + expect(read(rel), rel).toMatch( + /if \(auth\.authMethod === "cookie"\) \{\s*await assertRecentCookieProof\(/, + ); + } }); it("each cookie-only route resolves a cookie before it asks", () => { diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts index 03631ffa8..45cb98d38 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -318,3 +318,42 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => { } }); }); + +describe("GET /api/export — the whole record asks for a fresh proof", () => { + const stale = { + createdAt: new Date(Date.now() - 60 * 60 * 1000), + mfaVerifiedAt: null, + reproofAt: null, + }; + + it("type=all on a session that signed in an hour ago is asked to confirm", async () => { + vi.mocked(prisma.session.findUnique).mockResolvedValueOnce(stale as never); + const { GET } = await import("../route"); + const res = await GET(mkReq("http://localhost/api/export?type=all")); + expect(res.status).toBe(401); + expect((await res.json()).meta.errorCode).toBe("auth.reproof.required"); + expect(prisma.measurement.findMany).not.toHaveBeenCalled(); + }); + + it("no type means the whole record, and is gated the same way", async () => { + vi.mocked(prisma.session.findUnique).mockResolvedValueOnce(stale as never); + const { GET } = await import("../route"); + const res = await GET(mkReq("http://localhost/api/export")); + expect(res.status).toBe(401); + }); + + it("type=all right after signing in goes through", async () => { + const { GET } = await import("../route"); + const res = await GET(mkReq("http://localhost/api/export?type=all")); + expect(res.status).toBe(200); + }); + + it("a single-type export stays on the session alone", async () => { + vi.mocked(prisma.session.findUnique).mockResolvedValue(stale as never); + const { GET } = await import("../route"); + const res = await GET( + mkReq("http://localhost/api/export?type=measurements"), + ); + expect(res.status).toBe(200); + }); +}); diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 9a761419c..664e6fc4f 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -32,6 +32,17 @@ vi.mock("@/lib/db", () => ({ userHealthProfile: { findUnique: vi.fn() }, healthProfileFactRevision: { findMany: vi.fn() }, user: { findUnique: vi.fn(), update: vi.fn() }, + // The whole-record gate reads the session's own stamps: a sign-in a + // moment ago is the proof, on an account without a second factor. + session: { + findUnique: vi.fn(async () => ({ + createdAt: new Date(), + mfaVerifiedAt: null, + reproofAt: null, + })), + }, + webauthnMfaCredential: { count: vi.fn(async () => 0) }, + passkey: { count: vi.fn(async () => 0) }, }, toJson: (value: unknown) => value, })); @@ -908,3 +919,18 @@ describe("POST /api/export/health-record — the insurance leaf", () => { expect(text).toContain("A123456789"); }); }); + +describe("POST /api/export/health-record — a fresh proof on the browser", () => { + it("a session that signed in an hour ago is asked to confirm first", async () => { + vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({ + createdAt: new Date(Date.now() - 60 * 60 * 1000), + mfaVerifiedAt: null, + reproofAt: null, + } as never); + const { POST } = await import("../route"); + const res = await POST(mkReq({ format: "pdf", selection: allLeaves() })); + expect(res.status).toBe(401); + expect((await res.json()).meta.errorCode).toBe("auth.reproof.required"); + expect(prisma.measurement.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts index 9937fa4b8..282da0731 100644 --- a/src/app/api/export/health-record/route.ts +++ b/src/app/api/export/health-record/route.ts @@ -22,7 +22,11 @@ */ import { NextRequest, NextResponse } from "next/server"; -import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { + apiHandler, + assertRecentCookieProof, + requireRecordAuth, +} from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -88,7 +92,17 @@ export const POST = apiHandler(async (request: NextRequest) => { // v1.37.0 — MANAGE: the doctor report is the one export an invited manager // reaches, because it renders a prepared artefact from a declared selection // rather than handing over the record whole. - const { user, actor } = await requireRecordAuth("manage", "record"); + const auth = await requireRecordAuth("manage", "record"); + const { user, actor } = auth; + // A whole-record artefact: a browser session must have signed in or + // re-proved within five minutes, the gate every whole-record export takes. + // The proof is the person at the keyboard, so it is the actor's session + // that is asked, whether they export their own record or one they manage. + // A token is its own proof here, as before: the shipped app calls this + // route with its token and no elevation. + if (auth.authMethod === "cookie") { + await assertRecentCookieProof(actor, auth.session.id); + } annotate({ action: { name: "export.health-record.build" } }); // The whole doctor-report surface is the `doctorReport` module. Refuse with diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index 82a2c133e..0e26e7560 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -1,5 +1,5 @@ import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecentProof } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { apiError, getClientIp } from "@/lib/api-response"; @@ -24,12 +24,23 @@ import { NextRequest, NextResponse } from "next/server"; * Export user data. * Query params: * format: "csv" | "json" (default: json) - * type: "measurements" | "medications" | "intake" | "all" (default: all) + * type: "measurements" | "medications" | "intake" | "mood" | "all" + * (default: all) * - * Re-authentication is required via session (user must be logged in). + * `type=all` (the default) is the whole record in one file, so it takes the + * same fresh-proof gate as every other whole-record export + * (`requireRecentProof`): a browser session must have signed in or re-proved + * within five minutes, a token must present a step-up elevation. No shipped + * client calls this route with a token. The single-type exports stay on the + * session alone, like their dedicated routes under `/api/export/*`. */ export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const wholeRecord = + (new URL(request.url).searchParams.get("type") ?? "all") === "all"; + const auth = wholeRecord + ? await requireRecentProof({ bearer: "elevation" }) + : { ...(await requireAuth()), commitElevation: async () => {} }; + const { user } = auth; const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000); if (!rl.allowed) { @@ -54,6 +65,9 @@ export const GET = apiHandler(async (request: NextRequest) => { ); } + // Spend a Bearer elevation only once the request is about to act. + await auth.commitElevation(); + const userId = user.id; await auditLog("export.download", { userId, diff --git a/src/app/api/fhir/Patient/$everything/route.ts b/src/app/api/fhir/Patient/$everything/route.ts index bf596b780..68faf6304 100644 --- a/src/app/api/fhir/Patient/$everything/route.ts +++ b/src/app/api/fhir/Patient/$everything/route.ts @@ -13,12 +13,17 @@ * resolves inside the response and the operation can never quietly carry less * than the document does. * - * Read-only; `userId` narrowed from `requireAuth`. Offset paging applies across + * Read-only; `userId` narrowed from `requireAuth`. A browser session needs a + * recent sign-in or re-proof (`assertRecentCookieProof`). Offset paging applies across * the flattened resource list via `_count` (clamped ≤200) / `_offset`. */ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiHandler, + assertRecentCookieProof, + requireAuth, +} from "@/lib/api-handler"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -32,7 +37,16 @@ import { } from "@/lib/fhir/rest"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(FHIR_READ_SCOPE); + const auth = await requireAuth(FHIR_READ_SCOPE); + const { user } = auth; + // The whole record in one response: a browser session must have signed in + // or re-proved within five minutes, like every other whole-record export. + // A token is its own proof here, as before: the shipped app and the FHIR + // read tokens call this route without an elevation, and a refusal would + // read to the app as a dead session. + if (auth.authMethod === "cookie") { + await assertRecentCookieProof(user, auth.session.id); + } annotate({ action: { name: "fhir.everything.read" } }); // v1.30 — the FHIR REST face serves the SAME whole-record aggregate as diff --git a/src/app/api/fhir/__tests__/capability-and-everything.test.ts b/src/app/api/fhir/__tests__/capability-and-everything.test.ts index 22f1f077f..c402cf539 100644 --- a/src/app/api/fhir/__tests__/capability-and-everything.test.ts +++ b/src/app/api/fhir/__tests__/capability-and-everything.test.ts @@ -15,6 +15,21 @@ import { existsSync } from "node:fs"; import path from "node:path"; vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/db", () => ({ + prisma: { + // The whole-record gate reads the session's own stamps: a sign-in a + // moment ago is the proof, on an account without a second factor. + session: { + findUnique: vi.fn(async () => ({ + createdAt: new Date(), + mfaVerifiedAt: null, + reproofAt: null, + })), + }, + webauthnMfaCredential: { count: vi.fn(async () => 0) }, + passkey: { count: vi.fn(async () => 0) }, + }, +})); vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ @@ -39,6 +54,7 @@ vi.mock("next/headers", () => ({ import { GET as metadataGet } from "../metadata/route"; import { GET as everythingGet } from "../Patient/$everything/route"; +import { prisma } from "@/lib/db"; import { GET as observationRead } from "../Observation/[id]/route"; import { GET as patientRead } from "../Patient/[id]/route"; import { GET as coverageRead } from "../Coverage/[id]/route"; @@ -291,6 +307,17 @@ describe("GET /api/fhir/{type}/{id} — read", () => { }); describe("GET /api/fhir/Patient/$everything", () => { + it("asks a browser session that signed in an hour ago to confirm first", async () => { + vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({ + createdAt: new Date(Date.now() - 60 * 60 * 1000), + mfaVerifiedAt: null, + reproofAt: null, + } as never); + const res = await everythingGet(req("/api/fhir/Patient/$everything")); + expect(res.status).toBe(401); + expect((await res.json()).meta.errorCode).toBe("auth.reproof.required"); + }); + it("returns every resource family the document bundle carries", async () => { const res = await everythingGet( req("/api/fhir/Patient/$everything?_count=200"), diff --git a/src/app/api/fhir/__tests__/module-gate.test.ts b/src/app/api/fhir/__tests__/module-gate.test.ts index 40d328758..eb97a9833 100644 --- a/src/app/api/fhir/__tests__/module-gate.test.ts +++ b/src/app/api/fhir/__tests__/module-gate.test.ts @@ -32,6 +32,17 @@ vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, cycleProfile: { findUnique: vi.fn() }, + // The whole-record gate reads the session's own stamps: a sign-in a + // moment ago is the proof, on an account without a second factor. + session: { + findUnique: vi.fn(async () => ({ + createdAt: new Date(), + mfaVerifiedAt: null, + reproofAt: null, + })), + }, + webauthnMfaCredential: { count: vi.fn(async () => 0) }, + passkey: { count: vi.fn(async () => 0) }, }, })); vi.mock("@/lib/modules/operator-availability", async (importOriginal) => { diff --git a/src/components/settings/report-selection/selection-panel.tsx b/src/components/settings/report-selection/selection-panel.tsx index 3f589a20f..2f9f1875c 100644 --- a/src/components/settings/report-selection/selection-panel.tsx +++ b/src/components/settings/report-selection/selection-panel.tsx @@ -48,6 +48,11 @@ import { ReportVisitOffer } from "./report-visit-offer"; import { Switch } from "@/components/ui/switch"; import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { + recentProofErrorMessage, + throwIfReproofRequired, + useRecentProof, +} from "@/components/settings/security-section/use-recent-proof"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -67,6 +72,9 @@ type ExportFormat = "pdf" | "fhir" | "package"; const EXPORT_FORMATS: readonly ExportFormat[] = ["pdf", "fhir", "package"]; const PRESET_RANGES = [30, 90, 180, 365] as const; +/** A refusal already worded for the panel; shown as it is. */ +class ReportRequestError extends Error {} + export function HealthRecordExportPanel() { const { t, locale } = useTranslations(); const { user } = useAuth(); @@ -119,18 +127,40 @@ export function HealthRecordExportPanel() { onSelect: (index) => setFormat(EXPORT_FORMATS[index]!), }); + // The report is built from the whole record, so it asks for a fresh proof + // unless the session signed in or re-proved within five minutes. + const recentProof = useRecentProof(); + async function handleGenerate() { setBusy(true); setError(null); try { - const range = - customRange && startDate && endDate - ? { - startDate: new Date(`${startDate}T00:00:00Z`).toISOString(), - endDate: new Date(`${endDate}T23:59:59Z`).toISOString(), - } - : { days }; - const res = await apiFetchRaw("/api/export/health-record", { + await recentProof.run(generateOnce); + } catch (err) { + if (err instanceof ReportRequestError) { + setError(err.message); + return; + } + const message = recentProofErrorMessage( + err, + err instanceof Error ? err.message : String(err), + ); + if (message) setError(message); + } finally { + setBusy(false); + } + } + + async function generateOnce() { + const range = + customRange && startDate && endDate + ? { + startDate: new Date(`${startDate}T00:00:00Z`).toISOString(), + endDate: new Date(`${endDate}T23:59:59Z`).toISOString(), + } + : { days }; + const res = await throwIfReproofRequired( + await apiFetchRaw("/api/export/health-record", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", @@ -144,38 +174,33 @@ export function HealthRecordExportPanel() { includeCharts, selection: { v: 2, leaves: orderLeaves(selected) }, }), - }); - if (!res.ok) { - setError( - res.status === 429 - ? t("settings.healthRecord.errorRateLimit") - : res.status === 403 - ? t("settings.healthRecord.errorModuleDisabled") - : t("settings.healthRecord.errorGeneric"), - ); - return; - } - const blob = await res.blob(); - const ext = format === "pdf" ? "pdf" : format === "fhir" ? "json" : "zip"; - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `healthlog-health-record-${new Date() - .toISOString() - .slice(0, 10)}.${ext}`; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - // The route just persisted the practice name and the selection, so the - // cached `/me` payload is stale. Refresh it, or a remount would seed from - // the previous values. - void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setBusy(false); + }), + ); + if (!res.ok) { + throw new ReportRequestError( + res.status === 429 + ? t("settings.healthRecord.errorRateLimit") + : res.status === 403 + ? t("settings.healthRecord.errorModuleDisabled") + : t("settings.healthRecord.errorGeneric"), + ); } + const blob = await res.blob(); + const ext = format === "pdf" ? "pdf" : format === "fhir" ? "json" : "zip"; + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `healthlog-health-record-${new Date() + .toISOString() + .slice(0, 10)}.${ext}`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + // The route just persisted the practice name and the selection, so the + // cached `/me` payload is stale. Refresh it, or a remount would seed from + // the previous values. + void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); } return ( @@ -397,6 +422,7 @@ export function HealthRecordExportPanel() {

)} + {recentProof.dialog} ); } diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 4a43548a5..c26652e3b 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -332,7 +332,7 @@ const MFA_MANAGEMENT_AUTH_NOTE = " Accepts a cookie session, or a Bearer token presenting a single-use elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused."; const ENROLLMENT_PROOF_NOTE = - "\n\nAdding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof (a password-proved one is enough). On the cookie path the request passes when the session signed in, or completed a second factor, within the last five minutes; otherwise the body must carry a proof in the shape POST /api/auth/step-up takes (`password`, a `totp` code from an already confirmed authenticator, or a passkey / security-key assertion begun at POST /api/auth/passkey/register-options). Without one the answer is 401 `auth.reproof.required`; a proof that does not verify is 401 `auth.reproof.failed` and is audited. Proofs draw on the account's shared re-proof budget of five per fifteen minutes (429 once spent), the same budget as the step-up mint."; + "\n\nAdding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof: a password-proved one is enough on an account without a second factor, and on an account that already has one the elevation must come from that factor or a passkey. On the cookie path, for an account without a second factor, the request passes when the session signed in, or completed a passkey sign-in, within the last five minutes; otherwise the body must carry a proof in the shape POST /api/auth/step-up takes (`password`, or a passkey assertion begun at POST /api/auth/passkey/register-options). For an account that already has a second factor, only that factor counts: a second factor completed within the last five minutes, or a `totp` code, security-key or passkey assertion in the body. A password or a young session (for example one that skipped the factor on a remembered browser) is not enough there. Without one the answer is 401 `auth.reproof.required` with `meta.methods` naming the proofs the account can give; a proof that does not verify is 401 `auth.reproof.failed` and is audited. Proofs draw on the account's shared re-proof budget of five per fifteen minutes (429 once spent), the same budget as the step-up mint."; const enrollmentProofResponses = { "401": { @@ -2023,7 +2023,7 @@ export const authPaths: NonNullable = { description: "Cookie-only — a Bearer token cannot enroll a sign-in credential on this surface at any scope. The route has TWO arms and the body decides which.\n\n" + '**Re-proof arm.** A body of exactly `{ method: "passkey" }` or `{ method: "webauthn" }` begins an assertion ceremony WITHOUT beginning enrollment, and answers with `reauth: true` alongside the options. Use it to obtain the assertion the second arm needs.\n\n' + - "**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options issued.\n\n" + + "**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options issued. On an account that already has a second factor a password is refused before it is checked, with 422 `auth.reproof.too_weak` and `meta.methods` naming the proofs that work: a passkey added on the password alone would sign in without the second factor from then on.\n\n" + "Which factor you prove changes the session, and the direction is not the obvious one: a strong proof stamps the session second-factor-verified, while a PASSWORD proof deliberately CLEARS that stamp. Registering with a password is authorised by the single-use, session-bound challenge alone and must not silently upgrade password-only authentication into something the rest of the app reads as a second factor.\n\n" + "Every refusal on the proof path is the same 401 with the same prose — a missing content type, an unparseable body, a body that is not a valid proof shape, and a wrong password are indistinguishable to the caller. A `totp` proof counts only from an authenticator whose enrollment was confirmed.\n\n" + "Each proof on the enrollment arm draws on the account's shared re-proof budget (five per fifteen minutes, the same budget as POST /api/auth/step-up), and every failed proof writes an `auth.mfa.failed` audit row. Once the budget is spent the answer is 429 until the window passes, even for a correct proof.", @@ -2060,6 +2060,11 @@ export const authPaths: NonNullable = { content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, + "422": { + description: + "`auth.reproof.too_weak`: a password on an account that already has a second factor. `meta.methods` names the proofs that would be accepted. Refused before the password is checked, so it spends no attempt.", + content: { "application/json": { schema: errorEnvelope } }, + }, "401": { description: "No cookie session, or no acceptable existing-factor proof. One message covers every proof failure so the response cannot be used to probe which part was wrong.", diff --git a/src/lib/openapi/routes/export.ts b/src/lib/openapi/routes/export.ts index 9838e73fc..a19a5d09c 100644 --- a/src/lib/openapi/routes/export.ts +++ b/src/lib/openapi/routes/export.ts @@ -168,7 +168,7 @@ export const exportPaths: NonNullable = { tags: ["Export"], summary: "Download measurements, medications, intake and mood", description: - "The plaintext download. Returns `application/json` or `text/csv` (never the standard response envelope) with a `Content-Disposition: attachment` filename of `healthlog-export-.`. Measurements stream page by page, so a multi-year account does not buffer its whole history in memory; the other sections are assembled in full first. The CSV variant concatenates the selected sections under `# Measurements` / `# Medications` / `# Intake Events` / `# Mood Entries` headings, so it is a CONCATENATION of tables rather than one table — a single-table CSV parser will choke on it. Mood notes are decrypted into the download; measurement notes ride their exported column. Shares the `export:` bucket with the encrypted variant (10 per hour) and audits every attempt as `export.download`. Cookie or Bearer auth; the caller is always resolved as themselves, so this download cannot be delegated to a shared record. Not module-gated: a disabled module's rows are still exported, because this is the account's own data leaving on request.", + "The plaintext download. Returns `application/json` or `text/csv` (never the standard response envelope) with a `Content-Disposition: attachment` filename of `healthlog-export-.`. Measurements stream page by page, so a multi-year account does not buffer its whole history in memory; the other sections are assembled in full first. The CSV variant concatenates the selected sections under `# Measurements` / `# Medications` / `# Intake Events` / `# Mood Entries` headings, so it is a CONCATENATION of tables rather than one table — a single-table CSV parser will choke on it. Mood notes are decrypted into the download; measurement notes ride their exported column. Shares the `export:` bucket with the encrypted variant (10 per hour) and audits every attempt as `export.download`. Cookie or Bearer auth; the caller is always resolved as themselves, so this download cannot be delegated to a shared record. Not module-gated: a disabled module's rows are still exported, because this is the account's own data leaving on request. `type=all`, the default, is the whole record and asks for a fresh proof like the full backup; the single-type exports do not. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. A Bearer caller asking for `type=all` presents an `X-Step-Up` elevation from `POST /api/auth/step-up` (a second-factor one on an account with a second factor) and receives 401 `auth.stepup.required` without it.", requestParams: { query: exportQuery }, responses: { "200": { diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts index 6ffd297a5..43845b213 100644 --- a/src/lib/openapi/routes/health-record.ts +++ b/src/lib/openapi/routes/health-record.ts @@ -113,7 +113,7 @@ export const healthRecordPaths: NonNullable = { tags: ["Export"], summary: "Generate a health-record export (PDF / FHIR / package)", description: - "Returns the doctor-handover artefact in the requested `format`: `pdf` → application/pdf, `fhir` → application/fhir+json (HL7 FHIR R4 document Bundle), `package` → application/zip (PDF + FHIR + README). `selection` is REQUIRED and carries the leaf inclusion list — membership is inclusion, absence is exclusion, and there is no server-side default. An unknown leaf id is a 422 that names it; the retired grouped `sections` shape is a 422 too, because accepting it would mean choosing a scope the caller did not express. Auth via cookie or Bearer; shared `export:` rate bucket (10/h). Strict validation: unknown keys 422.", + "Returns the doctor-handover artefact in the requested `format`: `pdf` → application/pdf, `fhir` → application/fhir+json (HL7 FHIR R4 document Bundle), `package` → application/zip (PDF + FHIR + README). `selection` is REQUIRED and carries the leaf inclusion list — membership is inclusion, absence is exclusion, and there is no server-side default. An unknown leaf id is a 422 that names it; the retired grouped `sections` shape is a 422 too, because accepting it would mean choosing a scope the caller did not express. Auth via cookie or Bearer; shared `export:` rate bucket (10/h). Strict validation: unknown keys 422. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. When a manager exports a record they manage, it is their own session that is asked. A Bearer caller is not asked for more than its token.", requestBody: { required: true, content: { @@ -647,7 +647,7 @@ export const healthRecordPaths: NonNullable = { tags: ["FHIR"], summary: "FHIR R4 $everything (v1.11.0)", description: - "Read-only `Patient/$everything`: every resource the document bundle carries — Composition, Device, Patient, Coverage, Observations (including the cycle ones), MedicationStatements, MedicationAdministrations, Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport (present when the window holds a vital sign) — flattened into one `searchset` Bundle. Scoped to the owner's SAVED report selection; an account that never saved one gets nothing. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.", + "Read-only `Patient/$everything`: every resource the document bundle carries — Composition, Device, Patient, Coverage, Observations (including the cycle ones), MedicationStatements, MedicationAdministrations, Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport (present when the window holds a vital sign) — flattened into one `searchset` Bundle. Scoped to the owner's SAVED report selection; an account that never saved one gets nothing. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. A token, including a `fhir:read` one, is not asked for more.", requestParams: { query: z.object({ _count: z.coerce.number().optional(),