mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
0692017f34
commit
a4d27c618e
13 files changed
+304
-76
No files matched your search
+50
-21
@@ -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:<userId>` rate bucket (10/h). Strict validation: unknown keys 422."
|
||||
Bearer; shared `export:<userId>` 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:<userId>` 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:
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{recentProof.dialog}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
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<ZodOpenApiObject["paths"]> = {
|
||||
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.",
|
||||
|
||||
@@ -168,7 +168,7 @@ export const exportPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
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-<YYYY-MM-DD>.<ext>`. 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:<userId>` 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-<YYYY-MM-DD>.<ext>`. 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:<userId>` 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": {
|
||||
|
||||
@@ -113,7 +113,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
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:<userId>` 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:<userId>` 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<ZodOpenApiObject["paths"]> = {
|
||||
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(),
|
||||
|
||||
Reference in new issue
Block a user