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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 22:21:43 +02:00
1 parent 0692017f34
commit a4d27c618e
13 files changed
+304 -76

No files matched your search

@@ -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);
});
});
@@ -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();
});
});
+16 -2
View File
@@ -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
+18 -4
View File
@@ -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,
+17 -3
View File
@@ -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
@@ -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"),
@@ -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) => {