mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +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
@@ -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