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

@@ -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<string, string> = {
// `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<string, string> = {
"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", () => {