From 0fc83cb0a1da04d8da7d025120126159df37292e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 19:54:38 +0200 Subject: [PATCH] fix(auth): ask for a fresh proof before the record or a credential to it leaves Exporting the whole record, making a clinician share link, minting a token, and the admin backup download, upload and restore, data wipe and password reset ran on a live session alone. The plaintext full backup did, while the encrypted one beside it asked for a second factor. Whoever held a stolen session could take the record, or leave behind a credential that outlives the session. These now go through one gate, requireRecentProof (assertRecentCookieProof for the routes that resolve their caller themselves). A browser session passes after a sign-in or re-proof within five minutes: a second factor on an account that has one, otherwise the password or a passkey. The refusal is 401 auth.reproof.required with meta.methods, and the web opens the re-proof dialog, posts the proof to the new POST /api/auth/reproof and retries. A password proof stamps the new Session.reproofAt (migration 0359), never mfaVerifiedAt, so it cannot stand in for a second factor anywhere. On Bearer, the full backup asks for an X-Step-Up elevation; the encrypted export asks for a second-factor elevation on an account that has one. Share links and connector tokens keep taking the token alone, because the shipped app calls them without an elevation and reads a 401 there as a dead session; the guard test names them. Key rotation keeps its stricter second-factor gate. --- docs/api/openapi.yaml | 199 +++++++++++++--- messages/de.json | 8 +- messages/en.json | 8 +- messages/es.json | 8 +- messages/fr.json | 8 +- messages/it.json | 8 +- messages/ko.json | 8 +- messages/pl.json | 8 +- .../0359_session_reproof_stamp/migration.sql | 9 + prisma/schema.prisma | 7 + .../factor-verification-surface-guard.test.ts | 1 + .../recent-proof-surface-guard.test.ts | 156 +++++++++++++ src/__tests__/step-up-elevation-guard.test.ts | 13 +- .../api/admin/backups/[id]/download/route.ts | 12 +- .../api/admin/backups/[id]/restore/route.ts | 12 +- src/app/api/admin/backups/upload/route.ts | 12 +- src/app/api/admin/data/route.ts | 11 +- .../admin/users/[id]/reset-password/route.ts | 11 +- src/app/api/auth/reproof/route.ts | 139 +++++++++++ .../export/__tests__/per-type-routes.test.ts | 9 + .../__tests__/soft-delete-filter.test.ts | 12 +- .../export/encrypted/__tests__/route.test.ts | 41 ++++ src/app/api/export/encrypted/route.ts | 33 ++- src/app/api/export/full-backup/route.ts | 12 +- .../api/mcp/tokens/__tests__/route.test.ts | 24 ++ src/app/api/mcp/tokens/route.ts | 8 +- .../api/share-links/__tests__/route.test.ts | 25 ++ src/app/api/share-links/route.ts | 9 +- .../tokens/documents/__tests__/route.test.ts | 29 ++- src/app/api/tokens/documents/route.ts | 12 +- .../measurements/__tests__/route.test.ts | 29 ++- src/app/api/tokens/measurements/route.ts | 12 +- src/components/admin/backups-section.tsx | 126 ++++++---- src/components/admin/danger-zone-section.tsx | 29 ++- .../admin/user-management-section.tsx | 15 +- src/components/settings/api-section.tsx | 32 ++- src/components/settings/export-section.tsx | 78 ++++--- src/components/settings/mcp-section.tsx | 35 ++- .../__tests__/recent-proof-callers.test.tsx | 91 ++++++++ .../existing-factor-reauth-dialog.tsx | 5 +- .../security-section/use-recent-proof.tsx | 181 ++++++++++++++ .../settings/share-link-create-form.tsx | 31 ++- .../__tests__/require-recent-proof.test.ts | 221 ++++++++++++++++++ src/lib/api-errors.ts | 14 ++ src/lib/api-handler.ts | 148 +++++++++++- src/lib/auth/existing-factor-proof.ts | 23 +- src/lib/openapi/error-codes.ts | 1 + src/lib/openapi/routes/admin-backups.ts | 4 +- src/lib/openapi/routes/auth.ts | 61 ++++- src/lib/openapi/routes/export.ts | 4 +- src/lib/openapi/routes/health-record.ts | 2 +- src/lib/openapi/routes/mcp.ts | 2 +- tests/integration/admin-data-wipe.test.ts | 3 + .../bearer-scope-enforcement.test.ts | 30 ++- tests/integration/export-per-type.test.ts | 22 +- tests/integration/recent-proof.test.ts | 212 +++++++++++++++++ 56 files changed, 2028 insertions(+), 235 deletions(-) create mode 100644 prisma/migrations/0359_session_reproof_stamp/migration.sql create mode 100644 src/__tests__/recent-proof-surface-guard.test.ts create mode 100644 src/app/api/auth/reproof/route.ts create mode 100644 src/components/settings/security-section/__tests__/recent-proof-callers.test.tsx create mode 100644 src/components/settings/security-section/use-recent-proof.tsx create mode 100644 src/lib/__tests__/require-recent-proof.test.ts create mode 100644 tests/integration/recent-proof.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 44f261259..08a56d849 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -913,7 +913,11 @@ paths: `INSURANCE` leaf is refused with a 422 because the share view never decrypts the insurance number. An optional `documentIds` array (bounded ≤50) is validated as the caller's own live documents and frozen onto the link; a documents-only share (no report scope, non-empty `documentIds`) is valid. `expiresAt` is required and capped at - 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). Strict: unknown keys 422." + 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). Strict: 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. A Bearer caller is not + asked for more than its token yet." requestBody: required: true content: @@ -1582,7 +1586,11 @@ paths: `Content-Disposition: attachment` with `healthlog-backup--.json`, `Cache-Control: no-store`. Shares the `export:` bucket (10 per hour) with every other export route. Audits as `user.export.full-backup` with the row counts. Cookie or Bearer auth; the caller is always resolved as - themselves, so this download cannot be delegated to a shared record." + themselves, so this download cannot be delegated to a shared record. 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 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." responses: "200": description: The backup payload. `application/json; charset=utf-8`, served as an attachment. @@ -1783,12 +1791,15 @@ paths: tags: - Export summary: Download the full backup as a passphrase-encrypted archive - description: v1.23. Same restore-compatible payload as the plaintext full backup, sealed into an `HLX1` archive + description: "v1.23. Same restore-compatible payload as the plaintext full backup, sealed into an `HLX1` archive (Argon2id-derived key + AES-256-GCM) under the supplied `passphrase`. Returns `application/octet-stream` (a - `.hlx` file). When the account has a second factor enrolled, the call is step-up gated (`requireFreshMfa`, - cookie-only) and returns 401 `auth.stepup.required` without a fresh factor; single-factor accounts use a normal - session or Bearer. Shared `export:` rate bucket (10/h). The passphrase is never stored — a forgotten - passphrase makes the archive unrecoverable. + `.hlx` file). Asks for a fresh proof, like the plaintext full backup. 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. On Bearer, an account without a second factor passes on its token, and one with a + second factor presents a second-factor `X-Step-Up` elevation (401 `auth.stepup.required` without it). Shared + `export:` rate bucket (10/h). The passphrase is never stored — a forgotten passphrase makes the archive + unrecoverable." requestBody: required: true content: @@ -2058,9 +2069,12 @@ paths: Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE - /api/auth/me/mfa/webauthn/{id}; DELETE /api/settings/account; DELETE /api/settings/data. The last three MFA - routes, disable, and both erasure routes require a fresh-factor proof. GET /api/auth/me/mfa needs no elevation - at all. Nothing else accepts one — admin endpoints stay cookie-only. + /api/auth/me/mfa/webauthn/{id}; DELETE /api/settings/account; DELETE /api/settings/data; GET + /api/export/full-backup; POST /api/export/encrypted. The last three MFA routes, disable, and both erasure routes + require a fresh-factor proof. The two whole-record exports take any elevation on an account without a second + factor and a fresh-factor one on an account with one; the full backup requires an elevation on every Bearer + call, the encrypted export only on an account with a second factor. GET /api/auth/me/mfa needs no elevation at + all. Nothing else accepts one — admin endpoints stay cookie-only. The two erasure routes joined the set so an account with a second factor can be deleted from a native app: their @@ -2083,6 +2097,64 @@ paths: "401": *a1 "422": *a4 "429": *a5 + /api/auth/reproof: + post: + tags: + - Auth + summary: Re-prove a credential on a browser session (cookie only) + description: >- + Stamps the calling browser session as recently re-proved, so for the next five minutes it may run the actions + that ask for a fresh proof: GET /api/export/full-backup, POST /api/export/encrypted, POST /api/share-links, POST + /api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/documents, and the admin backup download, + upload and restore, the admin data wipe and the admin password reset. Those answer 401 `auth.reproof.required` + with `meta.methods` (the proofs this account can give) when the session signed in or re-proved more than five + minutes ago. + + + The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`, + `webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second + factor; on one without, `password` or `passkey`. A method the account cannot use here is refused with 422 + `auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also + refreshes the session's second-factor stamp, exactly as signing in again would. Assertions begin at POST + /api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`. + + + Proofs draw on the account's shared re-proof budget (five per fifteen minutes, 429 once spent). A proof that + does not verify is 401 `auth.reproof.failed` and is audited. Bearer callers use POST /api/auth/step-up instead. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/StepUpMintRequest" + responses: + "200": + description: Proof accepted; the session is stamped. + content: + application/json: + schema: + $ref: "#/components/schemas/ReproofEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + get: + tags: + - Auth + summary: Ask whether this browser session's proof is recent + description: "The question the gated routes ask, without acting: 200 `{ recent: true }` when the session signed in or + re-proved within five minutes (a second factor on an account that has one), otherwise 401 + `auth.reproof.required` with `meta.methods`. Lets a client re-prove before sending a large upload rather than + after it was refused." + responses: + "200": + description: The session's proof is recent. + content: + application/json: + schema: + $ref: "#/components/schemas/ReproofStatusEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 /api/auth/me/mfa/totp/setup: post: tags: @@ -2893,12 +2965,14 @@ paths: holder actually has. And it cannot mint another token, this endpoint included. - Minting requires a COOKIE SESSION. No Bearer credential reaches this endpoint at any scope, wildcard included — - not because a wildcard lacks the reach, but because of the lifetimes involved: a native access token lives a day - and what it could mint here lives a year, so admitting one would let a short-lived compromise leave behind a - credential that outlives revoking it. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; - 10 mints per user per minute, and at most 10 live tokens held at once. Tokens appear in `GET /api/tokens` and - are revoked at `DELETE /api/tokens/{id}` like any other — revoking frees a slot against the ceiling. + Minting requires a COOKIE SESSION with a fresh proof: a sign-in or `POST /api/auth/reproof` within the last five + minutes (with a second factor, on an account that has one), otherwise 401 `auth.reproof.required` with + `meta.methods`. No Bearer credential reaches this endpoint at any scope, wildcard included — not because a + wildcard lacks the reach, but because of the lifetimes involved: a native access token lives a day and what it + could mint here lives a year, so admitting one would let a short-lived compromise leave behind a credential that + outlives revoking it. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user + per minute, and at most 10 live tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at + `DELETE /api/tokens/{id}` like any other — revoking frees a slot against the ceiling. requestBody: required: true content: @@ -2985,10 +3059,10 @@ paths: it 403. - Minting requires a COOKIE SESSION, for the reason the measurement mint gives: a day-lived native access token - must not be able to leave behind a year-lived credential. Gated by the operator's instance-wide API switch. Body - capped at 16 KiB; 10 mints per user per minute, and at most 10 live document tokens held at once. Tokens appear - in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}`. + Minting requires a COOKIE SESSION with a fresh proof, as for the measurement mint, for the reason it gives: a + day-lived native access token must not be able to leave behind a year-lived credential. Gated by the operator's + instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute, and at most 10 live document + tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}`. requestBody: required: true content: @@ -20171,9 +20245,12 @@ paths: tags: - MCP summary: Mint an MCP connector token - description: Mints a `health:read` (optionally read+write) Bearer for the manual / stdio path and audits the mint. THE + description: "Mints a `health:read` (optionally read+write) Bearer for the manual / stdio path and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN — this is the only place it ever exists, and it cannot be retrieved again. Body - capped at 16 KiB. Auth via cookie or Bearer. + capped at 16 KiB. Auth via cookie or Bearer. 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 is not asked for more than its token yet." requestBody: required: true content: @@ -20858,7 +20935,9 @@ paths: description: "Decrypts one stored copy and returns the backup document itself, with `Content-Disposition: attachment` and `Cache-Control: no-store`. The response is the raw document, not the standard envelope — it is the artefact an operator keeps. The ciphertext stays in the database. Admin session cookie required; Bearer tokens cannot - reach admin endpoints." + reach admin endpoints. 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." parameters: - in: path name: id @@ -20922,7 +21001,10 @@ paths: snapshot was taken for, never the admin running it. The request refuses a missing confirmation, an unknown backup and a copy that cannot be decrypted; the file's own checks (schema, declared owner, manifest, documents) run in the job before anything is deleted, and a refusal there ends the job as `failed` with the same reason. - One restore per account at a time. Admin session cookie required; Bearer tokens cannot reach admin endpoints." + One restore per account at a time. Admin session cookie required; Bearer tokens cannot reach admin endpoints. 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." parameters: - in: path name: id @@ -30888,10 +30970,10 @@ components: analytics.invalid_query; anamnesis — anamnesis.fact.conflict, anamnesis.fact.currentExists, anamnesis.fact.invalidValue; auth — auth.admin.required, auth.already_authenticated, auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, - auth.reproof.failed, auth.reproof.required, auth.scope.insufficient, auth.stepup.required, - auth.token.expired, auth.token.invalid; backup — backup.payload.undecryptable, backup.restore.active, - backup.section.missing, backup.upload.too_large; body-site — body-site.invalid; coach — - coach.budget.exceeded, coach.conversation.invalidTitle, coach.conversation.notFound, + auth.reproof.failed, auth.reproof.required, auth.reproof.too_weak, auth.scope.insufficient, + auth.stepup.required, auth.token.expired, auth.token.invalid; backup — backup.payload.undecryptable, + backup.restore.active, backup.section.missing, backup.upload.too_large; body-site — body-site.invalid; + coach — coach.budget.exceeded, coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, @@ -37876,6 +37958,67 @@ components: - method - satisfiesFreshFactor additionalProperties: false + ReproofEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ReproofResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ReproofResponse: + type: object + properties: + method: + type: string + enum: + - password + - totp + - webauthn + - passkey + verifiedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + required: + - method + - verifiedAt + additionalProperties: false + ReproofStatusEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ReproofStatus" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ReproofStatus: + type: object + properties: + recent: + type: boolean + const: true + required: + - recent + additionalProperties: false TotpSetupEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index de3131ffc..d69d71eba 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5180,7 +5180,6 @@ "passphrasePlaceholder": "Passphrase (mind. 12 Zeichen)", "passphraseHelp": "Leitet den Archivschlüssel per Argon2id ab und versiegelt ihn mit AES-256-GCM. Speichere die Datei mit der Endung .hlx.", "noRecoveryWarning": "Es gibt keine Wiederherstellung — geht diese Passphrase verloren, lässt sich das Archiv nicht mehr öffnen. Bewahre sie sicher auf.", - "stepUpNeeded": "Bestätige deinen zweiten Faktor und versuche es erneut.", "detail": "Gleiche Form wie die wöchentliche automatische Sicherung. Einige Satzarten sind noch nicht enthalten — das Manifest der Datei nennt, was drinsteht.", "contents": "Deine Messwerte, Medikamente, Stimmung, Zyklus, Laborwerte, Krankheitsepisoden, Allergien, Familienanamnese, Nährstoffe, Workouts, dein Gesundheitsprofil und deine eigenen Messgrößen in einer JSON-Datei." } @@ -6710,7 +6709,12 @@ "checklistShown": "Die Checkliste ist wieder auf dem Dashboard.", "checklistNothingHidden": "Die Checkliste war nicht ausgeblendet; sie öffnet sich auf dem Dashboard." }, - "emailChangePassword": "Aktuelles Passwort (nötig, um die E-Mail-Adresse zu ändern)" + "emailChangePassword": "Aktuelles Passwort (nötig, um die E-Mail-Adresse zu ändern)", + "reproof": { + "description": "Damit werden deine Gesundheitsdaten weitergegeben oder exportiert. Bestätige, dass du es bist, um fortzufahren.", + "signInAgain": "Melde dich neu an und versuche es dann noch einmal. Dein Konto hat kein Passwort und keinen Passkey, den du hier bestätigen könntest.", + "failed": "Das hat nicht geklappt. Prüfe deine Eingabe und versuche es noch einmal." + } }, "admin": { "title": "Administration", diff --git a/messages/en.json b/messages/en.json index 66715d4b5..99aeb6569 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5180,7 +5180,6 @@ "passphrasePlaceholder": "Passphrase (min. 12 characters)", "passphraseHelp": "Derives the archive key with Argon2id and seals it with AES-256-GCM. Save the file with a .hlx extension.", "noRecoveryWarning": "There is no recovery — if you lose this passphrase, the archive cannot be opened. Keep it somewhere safe.", - "stepUpNeeded": "Confirm your second factor, then try again.", "detail": "Same shape as the weekly auto-backup. Some record types are not carried yet — the file's manifest names what is in it.", "contents": "Your measurements, medications, mood, cycle, labs, illness episodes, allergies, family history, nutrients, workouts, health profile and custom metrics in one JSON file." } @@ -6710,7 +6709,12 @@ "checklistShown": "The checklist is back on the dashboard.", "checklistNothingHidden": "The checklist was not hidden; it opens on the dashboard." }, - "emailChangePassword": "Current password (needed to change the email address)" + "emailChangePassword": "Current password (needed to change the email address)", + "reproof": { + "description": "This shares or exports your health data. Confirm it's you to continue.", + "signInAgain": "Sign in again, then try once more. Your account has no password or passkey to confirm here.", + "failed": "That did not work. Check what you entered and try again." + } }, "admin": { "title": "Administration", diff --git a/messages/es.json b/messages/es.json index a442d7db2..ce056adbd 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5180,7 +5180,6 @@ "passphrasePlaceholder": "Contraseña (mín. 12 caracteres)", "passphraseHelp": "Deriva la clave del archivo con Argon2id y lo sella con AES-256-GCM. Guarda el archivo con la extensión .hlx.", "noRecoveryWarning": "No hay recuperación: si pierdes esta contraseña, el archivo no se podrá abrir. Guárdala en un lugar seguro.", - "stepUpNeeded": "Confirma tu segundo factor y vuelve a intentarlo.", "detail": "El mismo formato que la copia automática semanal. Algunos tipos de registro aún no se incluyen: el manifiesto del archivo indica qué contiene.", "contents": "Tus mediciones, medicamentos, estado de ánimo, ciclo, analíticas, episodios de enfermedad, alergias, antecedentes familiares, nutrientes, entrenamientos, tu perfil de salud y tus métricas personalizadas en un archivo JSON." } @@ -6710,7 +6709,12 @@ "checklistShown": "La lista vuelve a estar en el panel.", "checklistNothingHidden": "La lista no estaba oculta; se abre en el panel." }, - "emailChangePassword": "Contraseña actual (necesaria para cambiar la dirección de correo)" + "emailChangePassword": "Contraseña actual (necesaria para cambiar la dirección de correo)", + "reproof": { + "description": "Esto comparte o exporta tus datos de salud. Confirma que eres tú para continuar.", + "signInAgain": "Vuelve a iniciar sesión y inténtalo de nuevo. Tu cuenta no tiene contraseña ni llave de acceso que confirmar aquí.", + "failed": "No ha funcionado. Revisa lo que has introducido e inténtalo de nuevo." + } }, "admin": { "title": "Administration", diff --git a/messages/fr.json b/messages/fr.json index 16b933bae..ff6576ba4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5180,7 +5180,6 @@ "passphrasePlaceholder": "Phrase secrète (12 caractères min.)", "passphraseHelp": "Dérive la clé de l'archive avec Argon2id et la scelle avec AES-256-GCM. Enregistrez le fichier avec l'extension .hlx.", "noRecoveryWarning": "Aucune récupération possible : si vous perdez cette phrase secrète, l'archive ne pourra pas être ouverte. Conservez-la en lieu sûr.", - "stepUpNeeded": "Confirmez votre second facteur, puis réessayez.", "detail": "Même format que la sauvegarde automatique hebdomadaire. Certains types d'enregistrement ne sont pas encore inclus : le manifeste du fichier indique ce qu'il contient.", "contents": "Vos mesures, médicaments, humeur, cycle, analyses, épisodes de maladie, allergies, antécédents familiaux, nutriments, séances, votre profil de santé et vos mesures personnalisées dans un seul fichier JSON." } @@ -6710,7 +6709,12 @@ "checklistShown": "La liste de départ est de retour sur le tableau de bord.", "checklistNothingHidden": "La liste n'était pas masquée ; elle s'ouvre sur le tableau de bord." }, - "emailChangePassword": "Mot de passe actuel (nécessaire pour changer l'adresse e-mail)" + "emailChangePassword": "Mot de passe actuel (nécessaire pour changer l'adresse e-mail)", + "reproof": { + "description": "Cette action partage ou exporte vos données de santé. Confirmez qu'il s'agit bien de vous pour continuer.", + "signInAgain": "Reconnectez-vous, puis réessayez. Votre compte n'a ni mot de passe ni clé d'accès à confirmer ici.", + "failed": "Cela n'a pas fonctionné. Vérifiez votre saisie et réessayez." + } }, "admin": { "title": "Administration", diff --git a/messages/it.json b/messages/it.json index 7f501602f..e19bac911 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5180,7 +5180,6 @@ "passphrasePlaceholder": "Passphrase (min. 12 caratteri)", "passphraseHelp": "Deriva la chiave dell'archivio con Argon2id e lo sigilla con AES-256-GCM. Salva il file con estensione .hlx.", "noRecoveryWarning": "Non c'è recupero: se perdi questa passphrase, l'archivio non potrà essere aperto. Conservala in un luogo sicuro.", - "stepUpNeeded": "Conferma il secondo fattore e riprova.", "detail": "Stesso formato del backup automatico settimanale. Alcuni tipi di dato non sono ancora inclusi: il manifest del file indica cosa contiene.", "contents": "Le tue misurazioni, i farmaci, l'umore, il ciclo, le analisi, gli episodi di malattia, le allergie, l'anamnesi familiare, i nutrienti, gli allenamenti, il tuo profilo di salute e le tue metriche personalizzate in un unico file JSON." } @@ -6710,7 +6709,12 @@ "checklistShown": "La lista è di nuovo nella dashboard.", "checklistNothingHidden": "La lista non era nascosta; si apre nella dashboard." }, - "emailChangePassword": "Password attuale (necessaria per cambiare l'indirizzo email)" + "emailChangePassword": "Password attuale (necessaria per cambiare l'indirizzo email)", + "reproof": { + "description": "Questa azione condivide o esporta i tuoi dati sanitari. Conferma che sei tu per continuare.", + "signInAgain": "Accedi di nuovo, poi riprova. Il tuo account non ha una password o una passkey da confermare qui.", + "failed": "Non ha funzionato. Controlla quanto inserito e riprova." + } }, "admin": { "title": "Administration", diff --git a/messages/ko.json b/messages/ko.json index ce93f5291..f3d6437fe 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5180,7 +5180,6 @@ "passphrasePlaceholder": "암호문구 (12자 이상)", "passphraseHelp": "Argon2id로 보관 파일 키를 만들고 AES-256-GCM으로 봉인해요. 파일은 .hlx 확장자로 저장해 주세요.", "noRecoveryWarning": "복구 수단이 없어요 — 이 암호문구를 잃어버리면 보관 파일을 열 수 없어요. 안전한 곳에 보관해 주세요.", - "stepUpNeeded": "2단계 인증을 확인한 뒤 다시 시도해 주세요.", "detail": "주간 자동 백업과 같은 형식이에요. 아직 담기지 않는 기록 유형이 있고, 파일의 매니페스트에 무엇이 들었는지 적혀 있어요.", "contents": "측정, 복약, 기분, 생리 주기, 검사, 아픈 날 에피소드, 알레르기, 가족력, 영양, 운동, 건강 프로필, 사용자 지표를 JSON 파일 하나에." } @@ -6710,7 +6709,12 @@ "checklistShown": "체크리스트가 대시보드에 다시 표시됩니다.", "checklistNothingHidden": "체크리스트는 숨겨져 있지 않았습니다. 대시보드에서 열립니다." }, - "emailChangePassword": "현재 비밀번호 (이메일 주소를 변경하려면 필요)" + "emailChangePassword": "현재 비밀번호 (이메일 주소를 변경하려면 필요)", + "reproof": { + "description": "건강 데이터를 공유하거나 내보내는 작업입니다. 계속하려면 본인임을 확인하세요.", + "signInAgain": "다시 로그인한 후 한 번 더 시도하세요. 이 계정에는 여기서 확인할 비밀번호나 패스키가 없습니다.", + "failed": "확인하지 못했습니다. 입력한 내용을 확인하고 다시 시도하세요." + } }, "admin": { "title": "관리자", diff --git a/messages/pl.json b/messages/pl.json index 30f4d34e6..3ec5fb99b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5180,7 +5180,6 @@ "passphrasePlaceholder": "Hasło-fraza (min. 12 znaków)", "passphraseHelp": "Wyprowadza klucz archiwum za pomocą Argon2id i pieczętuje je AES-256-GCM. Zapisz plik z rozszerzeniem .hlx.", "noRecoveryWarning": "Nie ma odzyskiwania — jeśli zgubisz tę frazę, archiwum nie da się otworzyć. Przechowuj ją bezpiecznie.", - "stepUpNeeded": "Potwierdź drugi składnik i spróbuj ponownie.", "detail": "Ten sam kształt co cotygodniowa kopia automatyczna. Niektóre rodzaje wpisów nie są jeszcze przenoszone — manifest pliku wskazuje, co zawiera.", "contents": "Twoje pomiary, leki, nastrój, cykl, wyniki badań, epizody chorobowe, alergie, wywiad rodzinny, składniki odżywcze, treningi, Twój profil zdrowia i własne wskaźniki w jednym pliku JSON." } @@ -6710,7 +6709,12 @@ "checklistShown": "Lista wróciła na panel.", "checklistNothingHidden": "Lista nie była ukryta; otwiera się na panelu." }, - "emailChangePassword": "Obecne hasło (potrzebne do zmiany adresu e-mail)" + "emailChangePassword": "Obecne hasło (potrzebne do zmiany adresu e-mail)", + "reproof": { + "description": "Ta czynność udostępnia lub eksportuje Twoje dane zdrowotne. Potwierdź, że to Ty, aby kontynuować.", + "signInAgain": "Zaloguj się ponownie, a potem spróbuj jeszcze raz. Twoje konto nie ma hasła ani klucza dostępu do potwierdzenia tutaj.", + "failed": "To się nie udało. Sprawdź wprowadzone dane i spróbuj ponownie." + } }, "admin": { "title": "Administracja", diff --git a/prisma/migrations/0359_session_reproof_stamp/migration.sql b/prisma/migrations/0359_session_reproof_stamp/migration.sql new file mode 100644 index 000000000..1eb53749d --- /dev/null +++ b/prisma/migrations/0359_session_reproof_stamp/migration.sql @@ -0,0 +1,9 @@ +-- When a browser session last re-proved a credential the account holds. +-- +-- Exporting the whole record, making a share link, minting a token and the +-- admin backup / reset / wipe actions now ask for a fresh proof, not just a +-- live session. A second-factor proof keeps stamping `mfa_verified_at`, as it +-- always has. A password proof on an account without a second factor stamps +-- this column instead, so a password never satisfies a gate that asks for a +-- second factor. Nullable, no default: a session that never re-proved has none. +ALTER TABLE "sessions" ADD COLUMN "reproof_at" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7d725e8d3..ee5febb59 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1168,6 +1168,13 @@ model Session { // v1.23 — step-up freshness stamp. Set when the session completes a second // factor (or re-confirms it); `requireFreshMfa` (a later wave) reads it. mfaVerifiedAt DateTime? @map("mfa_verified_at") + /// When this session last re-proved a credential through `POST + /// /api/auth/reproof` (any method). Read by `requireRecentProof`, which on an + /// account without a second factor accepts it as the fresh proof in front of + /// exports, share links, token minting and the admin data actions. A + /// second-factor proof also stamps `mfaVerifiedAt`; a password proof stamps + /// only this, so it never satisfies a gate that asks for a second factor. + reproofAt DateTime? @map("reproof_at") // v1.23 — sliding "last seen" for the user-facing active-session list. lastActiveAt DateTime? @map("last_active_at") diff --git a/src/__tests__/factor-verification-surface-guard.test.ts b/src/__tests__/factor-verification-surface-guard.test.ts index f4a9406f7..23cb148e4 100644 --- a/src/__tests__/factor-verification-surface-guard.test.ts +++ b/src/__tests__/factor-verification-surface-guard.test.ts @@ -46,6 +46,7 @@ const EXPECTED = [ "api/auth/passkey/login-verify/route.ts", "api/auth/passkey/register-options/route.ts", "api/auth/password/route.ts", + "api/auth/reproof/route.ts", "api/auth/step-up/route.ts", ]; diff --git a/src/__tests__/recent-proof-surface-guard.test.ts b/src/__tests__/recent-proof-surface-guard.test.ts new file mode 100644 index 000000000..26fce210d --- /dev/null +++ b/src/__tests__/recent-proof-surface-guard.test.ts @@ -0,0 +1,156 @@ +/** + * Structural guard on the recent-proof gate. + * + * Exporting the whole record, making a clinician share link, minting a token, + * and the admin backup, restore, wipe and reset actions ask for a fresh proof, + * not only a live session (`requireRecentProof` / `assertRecentCookieProof` in + * `src/lib/api-handler.ts`). This file pins which routes those are, so a route + * cannot drop the gate, and a new one cannot quietly join the list of routes + * whose Bearer arm still takes the token alone. + * + * Matching is on the IMPORT and then on calls of the local name, the way + * `step-up-elevation-guard.test.ts` does it, so an aliased import is followed. + * Every sweep asserts it matched something: a matcher that finds nothing agrees + * with every list. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +function sourceFiles(): string[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .sort(); +} + +function importedAs(rel: string, exportName: string): string[] { + const names: string[] = []; + for (const m of read(rel).matchAll( + /import\s*\{([\s\S]*?)\}\s*from\s*["']([^"']+)["']/g, + )) { + if (!/api-handler$/.test(m[2])) continue; + for (const part of m[1].split(",")) { + const piece = part.trim().replace(/^type\s+/, ""); + const aliased = piece.match( + /^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/, + ); + if (aliased && aliased[1] === exportName) names.push(aliased[2]); + else if (piece === exportName) names.push(piece); + } + } + return names; +} + +/** Every call of `exportName` in `rel`, as the source text of its argument. */ +function callArgs(rel: string, exportName: string): string[] { + const src = read(rel); + const args: string[] = []; + for (const local of importedAs(rel, exportName)) { + for (const m of src.matchAll( + new RegExp(`\\b${local}\\s*\\(([^)]*)\\)`, "g"), + )) { + args.push(m[1].replace(/\s+/g, " ").trim()); + } + } + return args; +} + +function callers(exportName: string): string[] { + return sourceFiles().filter( + (rel) => rel !== "lib/api-handler.ts" && callArgs(rel, exportName).length, + ); +} + +/** Routes that resolve their own caller and take the recent-proof gate. */ +const RECENT_PROOF_ROUTES: Record = { + "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 + // reads a 401 outside its known paths as a dead session. Tightening them is + // an app release first; see the iOS note for v1.39.3. + "app/api/share-links/route.ts": "token", + "app/api/mcp/tokens/route.ts": "token", +}; + +/** Cookie-only routes that add the gate after their own resolver. */ +const COOKIE_PROOF_ROUTES = [ + "app/api/admin/backups/[id]/download/route.ts", + "app/api/admin/backups/[id]/restore/route.ts", + "app/api/admin/backups/upload/route.ts", + "app/api/admin/data/route.ts", + "app/api/admin/users/[id]/reset-password/route.ts", + "app/api/auth/reproof/route.ts", + "app/api/tokens/documents/route.ts", + "app/api/tokens/measurements/route.ts", +].sort(); + +describe("the recent-proof gate", () => { + it("reads the tree it claims to sweep", () => { + expect(sourceFiles().length).toBeGreaterThan(1500); + }); + + it("is taken by exactly the known routes, each with its Bearer rule", () => { + const found: Record = {}; + for (const rel of callers("requireRecentProof")) { + const args = callArgs(rel, "requireRecentProof"); + expect(args).toHaveLength(1); + const rule = args[0].match(/bearer:\s*"([^"]+)"/)?.[1]; + expect(rule, rel).toBeDefined(); + found[rel] = rule!; + } + expect(Object.keys(found).length).toBeGreaterThan(0); + expect(found).toEqual(RECENT_PROOF_ROUTES); + }); + + it("the cookie arm is added by exactly the known cookie-only routes", () => { + const found = callers("assertRecentCookieProof"); + expect(found.length).toBeGreaterThan(0); + expect(found).toEqual(COOKIE_PROOF_ROUTES); + }); + + it("each cookie-only route resolves a cookie before it asks", () => { + for (const rel of COOKIE_PROOF_ROUTES) { + const resolvers = [ + ...importedAs(rel, "requireAdmin"), + ...importedAs(rel, "requireCookieAuth"), + ]; + expect(resolvers, rel).toHaveLength(1); + expect(importedAs(rel, "requireAuth"), rel).toEqual([]); + } + }); + + it("every route whose Bearer arm takes an elevation spends it", () => { + const withElevation = Object.entries(RECENT_PROOF_ROUTES).filter( + ([, rule]) => rule !== "token", + ); + expect(withElevation.length).toBeGreaterThan(0); + for (const [rel] of withElevation) { + expect(read(rel), rel).toMatch(/\.commitElevation\(\)/); + } + }); + + it("key rotation keeps its stricter second-factor gate", () => { + const rel = "app/api/admin/encryption/rotate/route.ts"; + expect(callArgs(rel, "requireFreshMfa").length).toBeGreaterThan(0); + }); + + it("the gate reads the session's own stamps, and only a second factor on an enrolled account", () => { + const src = read("lib/api-handler.ts"); + const start = src.indexOf("export async function assertRecentCookieProof("); + expect(start).toBeGreaterThan(-1); + const body = src.slice(start, src.indexOf("\nexport ", start + 1)); + // The enrolled arm names mfaVerifiedAt alone: a password re-proof + // (`reproofAt`) or a bare sign-in (`createdAt`) must not stand in for it. + expect(body).toMatch(/enrolled\s*\?\s*fresh\(row\?\.mfaVerifiedAt\)\s*:/); + }); +}); diff --git a/src/__tests__/step-up-elevation-guard.test.ts b/src/__tests__/step-up-elevation-guard.test.ts index 1b8bb5fdd..366abc900 100644 --- a/src/__tests__/step-up-elevation-guard.test.ts +++ b/src/__tests__/step-up-elevation-guard.test.ts @@ -118,11 +118,14 @@ function callers(exportName: string, moduleRe: RegExp): string[] { * consult this mechanism at all; * - the trusted-device routes. "Remember this browser" is a browser concept * with no native equivalent to manage; - * - `requireFreshMfaIfEnrolled`'s callers (password change, encrypted - * export, encryption-key rotation, the sharing invite). Those are - * destructive account actions, not second-factor management, and letting an - * elevation satisfy them would be exactly the silent widening this file - * exists to prevent. + * - `requireFreshMfaIfEnrolled`'s callers (password change, encryption-key + * rotation, the sharing invite). Those are destructive account actions, + * not second-factor management, and letting an elevation satisfy them + * would be exactly the silent widening this file exists to prevent. + * + * The two whole-record exports reach the elevation through a third gate, + * `requireRecentProof`, whose route list and per-route Bearer rule are frozen + * in `recent-proof-surface-guard.test.ts`. * * Account deletion and the record wipe used to sit in that last bullet. They * left it deliberately, and through a different gate: see `ERASURE_ROUTES` diff --git a/src/app/api/admin/backups/[id]/download/route.ts b/src/app/api/admin/backups/[id]/download/route.ts index 4db7bd51b..31b703a7e 100644 --- a/src/app/api/admin/backups/[id]/download/route.ts +++ b/src/app/api/admin/backups/[id]/download/route.ts @@ -18,7 +18,12 @@ import { Readable } from "node:stream"; import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler"; +import { + apiHandler, + HttpError, + requireAdmin, + assertRecentCookieProof, +} from "@/lib/api-handler"; import { apiError, getClientIp } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { @@ -42,7 +47,10 @@ export const GET = apiHandler( request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) => { - const { user: admin } = await requireAdmin(); + const { user: admin, session } = await requireAdmin(); + // A fresh proof on top of the admin session: this reaches every account's + // data, so a stolen admin session alone must not be enough. + await assertRecentCookieProof(admin, session.id); const { id } = await params; annotate({ action: { name: "admin.backups.download" }, meta: { id } }); diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index f9949a15b..476e47073 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -20,7 +20,12 @@ */ import { NextRequest } from "next/server"; -import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler"; +import { + apiHandler, + HttpError, + requireAdmin, + assertRecentCookieProof, +} from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -45,7 +50,10 @@ const handler = apiHandler( request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) => { - const { user: admin } = await requireAdmin(); + const { user: admin, session } = await requireAdmin(); + // A fresh proof on top of the admin session: this reaches every account's + // data, so a stolen admin session alone must not be enough. + await assertRecentCookieProof(admin, session.id); const { id } = await params; annotate({ action: { name: "admin.backups.restore" }, meta: { id } }); diff --git a/src/app/api/admin/backups/upload/route.ts b/src/app/api/admin/backups/upload/route.ts index 10eef312a..b2b0e398f 100644 --- a/src/app/api/admin/backups/upload/route.ts +++ b/src/app/api/admin/backups/upload/route.ts @@ -35,7 +35,12 @@ import { BodyTooLargeError, readBoundedBody } from "@/lib/labs/ocr-upload"; import { NextRequest } from "next/server"; import { ZodError } from "zod/v4"; import { prisma } from "@/lib/db"; -import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler"; +import { + apiHandler, + HttpError, + requireAdmin, + assertRecentCookieProof, +} from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { BackupJsonError, scanBackupJson } from "@/lib/export/backup-json-scan"; @@ -143,7 +148,10 @@ async function* decodedBytes( } export const POST = apiHandler(async (request: NextRequest) => { - const { user: admin } = await requireAdmin(); + const { user: admin, session } = await requireAdmin(); + // A fresh proof on top of the admin session: this reaches every account's + // data, so a stolen admin session alone must not be enough. + await assertRecentCookieProof(admin, session.id); annotate({ action: { name: "admin.backups.upload" } }); const ipAddress = getClientIp(request); diff --git a/src/app/api/admin/data/route.ts b/src/app/api/admin/data/route.ts index 9fcbb9afe..68cdbc007 100644 --- a/src/app/api/admin/data/route.ts +++ b/src/app/api/admin/data/route.ts @@ -1,5 +1,9 @@ import { prisma } from "@/lib/db"; -import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { + apiHandler, + requireAdmin, + assertRecentCookieProof, +} from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; @@ -47,7 +51,10 @@ export const DELETE = apiHandler(async (request: NextRequest) => { ); } - const { user } = await requireAdmin(); + const { user, session } = await requireAdmin(); + // A fresh proof on top of the admin session: this reaches every account's + // data, so a stolen admin session alone must not be enough. + await assertRecentCookieProof(user, session.id); annotate({ action: { name: "admin.data.delete" } }); // v1.18.1 — the documented convention buckets authenticated admin diff --git a/src/app/api/admin/users/[id]/reset-password/route.ts b/src/app/api/admin/users/[id]/reset-password/route.ts index 024ccb29a..ef8400c7b 100644 --- a/src/app/api/admin/users/[id]/reset-password/route.ts +++ b/src/app/api/admin/users/[id]/reset-password/route.ts @@ -1,5 +1,9 @@ import { prisma } from "@/lib/db"; -import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { + apiHandler, + requireAdmin, + assertRecentCookieProof, +} from "@/lib/api-handler"; import { hashPassword, checkPasswordStrength } from "@/lib/auth/password"; import { auditLog } from "@/lib/auth/audit"; import { @@ -31,7 +35,10 @@ export const POST = apiHandler( ); } - const { user } = await requireAdmin(); + const { user, session } = await requireAdmin(); + // A fresh proof on top of the admin session: this reaches every account's + // data, so a stolen admin session alone must not be enough. + await assertRecentCookieProof(user, session.id); const { id } = await params; annotate({ diff --git a/src/app/api/auth/reproof/route.ts b/src/app/api/auth/reproof/route.ts new file mode 100644 index 000000000..0f39e7d13 --- /dev/null +++ b/src/app/api/auth/reproof/route.ts @@ -0,0 +1,139 @@ +/** + * POST /api/auth/reproof + * + * A browser session re-proves a credential the account holds, so that for the + * next five minutes it may export the whole record, make a share link, mint a + * token, or run an admin backup, restore, wipe or reset. Those routes answer + * 401 `auth.reproof.required` with `meta.methods` when the session carries no + * recent proof; the web opens the re-proof dialog, posts the proof here, and + * retries. + * + * What a proof stamps on the session row: + * - `totp`, `webauthn` or `passkey`: `mfaVerifiedAt` and `reproofAt`. The + * same stamp a completed second factor or passkey sign-in writes, from the + * same factor, so it also clears the fresh-factor routes, exactly as + * signing in again would. + * - `password`: `reproofAt` only. Accepted only on an account without a + * second factor; an account with one is told which proofs it can give + * instead (422 `auth.reproof.too_weak`), because a password has never + * stood in for the second factor. + * + * Cookie-only. The Bearer transport has its own proof flow (`POST + * /api/auth/step-up`, token-bound single-use elevations), and a stamp on a + * session row means nothing to a token. + * + * GET asks the same question the gated routes ask, without acting: 200 when + * the session's proof is recent, otherwise the same 401 `auth.reproof.required` + * with `meta.methods`. The web calls it before a large upload, so the file is + * not sent twice around a re-proof. + * + * Proofs draw on the account's shared re-proof budget (five per fifteen + * minutes, the same bucket as the step-up mint and the enrollment routes) and + * every refusal is audited. Failures are indistinguishable on the wire. + */ +import { NextRequest } from "next/server"; +import { + apiHandler, + assertRecentCookieProof, + recentProofMethods, + requireCookieAuth, +} from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + apiValidationError, + getClientIp, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { annotate } from "@/lib/logging/context"; +import { + REPROOF_FAILED_CODE, + recordReproofFailure, + throttleReproof, + verifyExistingFactorProof, +} from "@/lib/auth/existing-factor-proof"; +import { stepUpMintSchema } from "@/lib/validations/step-up"; + +export const dynamic = "force-dynamic"; + +export const POST = apiHandler(async (request: NextRequest) => { + const { user, session } = await requireCookieAuth(); + const ip = getClientIp(request); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = stepUpMintSchema.safeParse(body); + if (!parsed.success) { + return apiValidationError( + "Invalid request", + sanitiseZodIssues(parsed.error.issues), + 422, + ); + } + const proof = parsed.data; + + // Refuse a proof that could not satisfy the gate BEFORE spending a guess on + // it: a password on an account with a second factor would verify and still + // leave every gated action refused. + const methods = await recentProofMethods(user); + if (!methods.includes(proof.method)) { + annotate({ + action: { name: "auth.reproof.method_refused" }, + meta: { method: proof.method }, + }); + return apiError("Use one of the listed ways to confirm it is you", 422, { + errorCode: "auth.reproof.too_weak", + methods, + }); + } + + const limited = await throttleReproof(user.id, ip, "reproof"); + if (limited) return limited; + + const outcome = await verifyExistingFactorProof(user, proof); + if (!outcome.ok) { + await recordReproofFailure( + user.id, + ip, + "reproof", + proof.method, + outcome.reason, + ); + return apiError("Verification failed", 401, { + errorCode: REPROOF_FAILED_CODE, + }); + } + + const now = new Date(); + await prisma.session.update({ + where: { id: session.id }, + data: + proof.method === "password" + ? { reproofAt: now } + : { reproofAt: now, mfaVerifiedAt: now }, + }); + + await auditLog("auth.reproof.succeeded", { + userId: user.id, + ipAddress: ip, + details: { method: proof.method }, + }); + annotate({ + action: { name: "auth.reproof.succeeded" }, + meta: { method: proof.method }, + }); + + return apiSuccess({ method: proof.method, verifiedAt: now.toISOString() }); +}); + +export const GET = apiHandler(async () => { + const { user, session } = await requireCookieAuth(); + await assertRecentCookieProof(user, session.id); + return apiSuccess({ recent: true }); +}); diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 4f6ea8736..a6a01a3e6 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -12,6 +12,15 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { + session: { + findUnique: vi.fn(async () => ({ + createdAt: new Date(), + mfaVerifiedAt: null, + reproofAt: null, + })), + }, + webauthnMfaCredential: { count: vi.fn(async () => 0) }, + passkey: { count: vi.fn(async () => 0) }, measurement: { findMany: vi.fn() }, medication: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, 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 ff386b3ec..03631ffa8 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -31,9 +31,17 @@ vi.mock("@/lib/db", () => ({ consentReceipt: { findMany: vi.fn() }, nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) }, user: { findUnique: vi.fn() }, - passkey: { findMany: vi.fn() }, + passkey: { findMany: vi.fn(), count: vi.fn(async () => 0) }, apiToken: { count: vi.fn() }, - session: { count: vi.fn() }, + session: { + count: vi.fn(), + findUnique: vi.fn(async () => ({ + createdAt: new Date(), + mfaVerifiedAt: null, + reproofAt: null, + })), + }, + webauthnMfaCredential: { count: vi.fn(async () => 0) }, appSettings: { findUnique: vi.fn() }, userAchievement: { findMany: vi.fn(), diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index d98b3ebf5..0f716e5d0 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -75,6 +75,7 @@ vi.mock("@/lib/db", () => ({ inboundDocument: { findMany: vi.fn().mockResolvedValue([]) }, session: { findUnique: vi.fn() }, webauthnMfaCredential: { count: vi.fn() }, + passkey: { count: vi.fn() }, }, })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); @@ -114,6 +115,14 @@ beforeEach(() => { // Default: no registered security key. Tests that exercise the // WebAuthn-only cohort override this. vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never); + vi.mocked(prisma.passkey.count).mockResolvedValue(0 as never); + // Default: a session that signed in a moment ago. The cases about the + // recent-proof gate set their own stamps. + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + createdAt: new Date(), + mfaVerifiedAt: null, + reproofAt: null, + } as never); }); describe("POST /api/export/encrypted", () => { @@ -147,6 +156,38 @@ describe("POST /api/export/encrypted", () => { }); }); + it("refuses a single-factor account whose session is not recent (401 reproof)", async () => { + vi.mocked(getSession).mockResolvedValue({ + user: { id: "user-1", role: "USER", totpConfirmedAt: null }, + session: { id: "sess-1" }, + } as never); + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + createdAt: new Date(Date.now() - 60 * 60 * 1000), + mfaVerifiedAt: null, + reproofAt: null, + } as never); + + const res = await POST(mkReq({ passphrase: PASSPHRASE })); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.meta.errorCode).toBe("auth.reproof.required"); + }); + + it("allows a single-factor account after a password re-proof", async () => { + vi.mocked(getSession).mockResolvedValue({ + user: { id: "user-1", role: "USER", totpConfirmedAt: null }, + session: { id: "sess-1" }, + } as never); + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + createdAt: new Date(Date.now() - 60 * 60 * 1000), + mfaVerifiedAt: null, + reproofAt: new Date(), + } as never); + + const res = await POST(mkReq({ passphrase: PASSPHRASE })); + expect(res.status).toBe(200); + }); + it("blocks an MFA account without a fresh second factor (401 step-up)", async () => { vi.mocked(getSession).mockResolvedValue({ user: { id: "user-1", role: "USER", totpConfirmedAt: new Date() }, diff --git a/src/app/api/export/encrypted/route.ts b/src/app/api/export/encrypted/route.ts index bd19ae009..70acef73a 100644 --- a/src/app/api/export/encrypted/route.ts +++ b/src/app/api/export/encrypted/route.ts @@ -9,10 +9,9 @@ * `application/octet-stream`. * * SECURITY: - * - Step-up: when the account has any second factor enrolled (TOTP OR a - * security key), this is gated by `requireFreshMfaIfEnrolled` — exporting the - * whole record is a sensitive action. Single-factor accounts fall back to a - * normal authenticated session. + * - Fresh proof: `requireRecentProof`, the gate every whole-record export + * shares. A second factor within five minutes on an account that has one; + * otherwise a recent sign-in or a password re-proof. * - The passphrase NEVER hits a log or wide-event: it is read off the parsed * body, passed straight into the KDF, and never `annotate()`d. The egress * redaction denylist already scrubs `/passphrase/i` (key-name) and the @@ -20,19 +19,16 @@ * - There is NO server-side recovery — the passphrase is not stored. A * forgotten passphrase means the archive is unrecoverable; the UI says so. * - * Auth: cookie session OR Bearer for single-factor accounts; cookie + fresh - * second factor for MFA accounts (Bearer can never satisfy step-up). + * Auth: cookie session with a recent proof; Bearer on the token alone for an + * account without a second factor, with a second-factor `X-Step-Up` + * elevation for one with. * Rate-limit: shared `export:` bucket (10/h) — same bucket as the * plaintext export so the encrypted variant cannot be used to bypass the cap. * Audit: `user.export.encrypted` with the row counts (never the passphrase). */ import { prisma } from "@/lib/db"; import { z } from "zod/v4"; -import { - apiHandler, - requireFreshMfaIfEnrolled, - MFA_STEP_UP_MAX_AGE_SECONDS, -} from "@/lib/api-handler"; +import { apiHandler, requireRecentProof } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -92,13 +88,13 @@ const encryptedExportSchema = z .strict(); export const POST = apiHandler(async (request: NextRequest) => { - // Resolve the session (cookie or Bearer), then escalate to a fresh second - // factor when the account has one enrolled. `requireFreshMfaIfEnrolled` - // covers BOTH cohorts — a confirmed TOTP secret AND a registered WebAuthn - // security key — so a security-key-only account is gated too. A single-factor - // account cannot produce a fresh-MFA proof, so it passes straight through - // rather than being locked out of its own encrypted export. - const auth = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS); + // A fresh proof, like every whole-record export (`requireRecentProof`). On + // the cookie path: a second factor within five minutes on an account that + // has one, otherwise a recent sign-in or password re-proof. On Bearer the + // shipped app calls this route without an elevation, so an account without + // a second factor still passes on its token; one with a second factor + // presents a second-factor elevation (before, it could not export at all). + const auth = await requireRecentProof({ bearer: "elevation-if-enrolled" }); const user = auth.user; annotate({ action: { name: "user.export.encrypted" } }); @@ -119,6 +115,7 @@ export const POST = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error); } const { passphrase } = parsed.data; + await auth.commitElevation(); // Sealed as it is produced and spooled to a temporary file, then sent: the // format puts the tag in front of the ciphertext, so the archive cannot go diff --git a/src/app/api/export/full-backup/route.ts b/src/app/api/export/full-backup/route.ts index b36201c92..a0581850a 100644 --- a/src/app/api/export/full-backup/route.ts +++ b/src/app/api/export/full-backup/route.ts @@ -25,12 +25,16 @@ * included; the payload's `manifest` field discloses both, and the export UI * states this too. * - * Auth: cookie session OR Bearer token (`requireAuth`). + * Auth: a fresh proof (`requireRecentProof`). The file is the whole record in + * plain text, so a live session alone is not enough: a cookie session needs a + * recent sign-in or re-proof (a second factor on an account that has one), a + * Bearer caller an `X-Step-Up` elevation. No shipped client calls this route + * on Bearer. * Rate-limit: shared `export:` bucket (10/h). * Audit: `user.export.full-backup` with the row counts. */ import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecentProof } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { apiError, getClientIp } from "@/lib/api-response"; @@ -41,13 +45,15 @@ import { streamToResponseBody } from "@/lib/export/response-stream"; import { NextRequest, NextResponse } from "next/server"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const auth = await requireRecentProof({ bearer: "elevation" }); + const { user } = auth; annotate({ action: { name: "user.export.full-backup" } }); const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000); if (!rl.allowed) { return apiError("Maximum 10 exports per hour", 429); } + await auth.commitElevation(); // Written into the response as it is produced (`streamFullBackupJson`), not // built and stringified first: for an account of 1.25 million measurements diff --git a/src/app/api/mcp/tokens/__tests__/route.test.ts b/src/app/api/mcp/tokens/__tests__/route.test.ts index 9455ccc45..e389130e3 100644 --- a/src/app/api/mcp/tokens/__tests__/route.test.ts +++ b/src/app/api/mcp/tokens/__tests__/route.test.ts @@ -10,6 +10,15 @@ vi.mock("@/lib/db", () => ({ prisma: { apiToken: { findMany: vi.fn() }, auditLog: { create: vi.fn() }, + 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/auth/session", () => ({ getSession: vi.fn() })); @@ -29,6 +38,7 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; import { isApiGloballyEnabled } from "@/lib/app-settings"; import { issueApiToken } from "@/lib/auth/issue-token"; @@ -57,6 +67,20 @@ beforeEach(() => { } as never); }); +describe("POST /api/mcp/tokens — fresh proof", () => { + it("refuses a browser session with no recent sign-in or re-proof", async () => { + vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({ + createdAt: new Date(Date.now() - 60 * 60 * 1000), + mfaVerifiedAt: null, + reproofAt: null, + } as never); + const res = await POST(postReq({ name: "laptop" })); + expect(res.status).toBe(401); + expect((await res.json()).meta.errorCode).toBe("auth.reproof.required"); + expect(issueApiToken).not.toHaveBeenCalled(); + }); +}); + describe("POST /api/mcp/tokens — scope minting", () => { it("defaults to read-only [health:read]", async () => { const res = await POST(postReq({ name: "laptop" })); diff --git a/src/app/api/mcp/tokens/route.ts b/src/app/api/mcp/tokens/route.ts index 0fe706e7d..14ccdb37e 100644 --- a/src/app/api/mcp/tokens/route.ts +++ b/src/app/api/mcp/tokens/route.ts @@ -19,7 +19,7 @@ */ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecentProof } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -71,7 +71,11 @@ export const GET = apiHandler(async () => { }); export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // A connector token outlives the session that mints it, so the cookie path + // needs a fresh proof. The shipped app mints these on its token without an + // elevation and reads a 401 here as a dead session, so the Bearer arm stays + // on the token until it sends one (see the iOS note). + const { user } = await requireRecentProof({ bearer: "token" }); annotate({ action: { name: "mcp.tokens.create" } }); if (!(await isApiGloballyEnabled())) { diff --git a/src/app/api/share-links/__tests__/route.test.ts b/src/app/api/share-links/__tests__/route.test.ts index 2de2d538a..f762f5d95 100644 --- a/src/app/api/share-links/__tests__/route.test.ts +++ b/src/app/api/share-links/__tests__/route.test.ts @@ -19,6 +19,15 @@ vi.mock("@/lib/db", () => ({ findMany: vi.fn(), }, auditLog: { create: vi.fn() }, + session: { + findUnique: vi.fn(async () => ({ + createdAt: new Date(), + mfaVerifiedAt: null, + reproofAt: null, + })), + }, + webauthnMfaCredential: { count: vi.fn(async () => 0) }, + passkey: { count: vi.fn(async () => 0) }, }, })); @@ -101,6 +110,22 @@ beforeEach(() => { } as never); }); +describe("POST /api/share-links — fresh proof", () => { + it("refuses a browser session with no recent sign-in or re-proof", async () => { + vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({ + createdAt: new Date(Date.now() - 60 * 60 * 1000), + mfaVerifiedAt: null, + reproofAt: null, + } as never); + const res = await POST(postReq(validBody())); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.meta.errorCode).toBe("auth.reproof.required"); + expect(body.meta.methods).toEqual([]); + expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled(); + }); +}); + describe("POST /api/share-links — create", () => { it("returns the raw token once and persists ONLY its hash", async () => { vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue( diff --git a/src/app/api/share-links/route.ts b/src/app/api/share-links/route.ts index 045056b99..e3c9fada9 100644 --- a/src/app/api/share-links/route.ts +++ b/src/app/api/share-links/route.ts @@ -15,7 +15,7 @@ import { randomBytes } from "node:crypto"; import { NextRequest } from "next/server"; -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 { hashToken } from "@/lib/auth/hmac"; @@ -136,7 +136,12 @@ function toSummary(row: { } export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // A share link is a standing credential to the record that works without + // signing in, so making one needs a fresh proof on the cookie path, not only + // a live session. The shipped app creates links on its token without an + // elevation, and reads any 401 here as a dead session, so the Bearer arm + // stays on the token until the app sends one (see the iOS note). + const { user } = await requireRecentProof({ bearer: "token" }); annotate({ action: { name: "share-link.create" } }); const rl = await checkRateLimit(`share-link:${user.id}`, 20, 60 * 60 * 1000); diff --git a/src/app/api/tokens/documents/__tests__/route.test.ts b/src/app/api/tokens/documents/__tests__/route.test.ts index d6cec1431..7f3f9fa36 100644 --- a/src/app/api/tokens/documents/__tests__/route.test.ts +++ b/src/app/api/tokens/documents/__tests__/route.test.ts @@ -14,6 +14,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/api-handler", () => ({ apiHandler: (fn: unknown) => fn, requireCookieAuth: vi.fn(), + assertRecentCookieProof: vi.fn(), })); vi.mock("@/lib/auth/issue-token", () => ({ @@ -42,7 +43,7 @@ vi.mock("@/lib/db", () => ({ })); import { POST } from "../route"; -import { requireCookieAuth } from "@/lib/api-handler"; +import { assertRecentCookieProof, requireCookieAuth } from "@/lib/api-handler"; import { issueApiToken } from "@/lib/auth/issue-token"; import { isApiGloballyEnabled } from "@/lib/app-settings"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -63,7 +64,11 @@ function req(body: unknown): Request { beforeEach(() => { vi.resetAllMocks(); - vi.mocked(requireCookieAuth).mockResolvedValue({ user: USER } as never); + vi.mocked(requireCookieAuth).mockResolvedValue({ + user: USER, + session: { id: "sess-1" }, + } as never); + vi.mocked(assertRecentCookieProof).mockResolvedValue(undefined); vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); vi.mocked(auditLog).mockResolvedValue(undefined as never); @@ -77,6 +82,26 @@ beforeEach(() => { } as never); }); +describe("the fresh proof in front of the mint", () => { + it("mints nothing when the session carries no recent proof", async () => { + vi.mocked(assertRecentCookieProof).mockRejectedValue( + new Error("auth.reproof.required"), + ); + await expect(POST(req({ name: "Scale" }) as never)).rejects.toThrow( + "auth.reproof.required", + ); + expect(issueApiToken).not.toHaveBeenCalled(); + }); + + it("asks about the caller's own session", async () => { + await POST(req({ name: "Scale" }) as never); + expect(assertRecentCookieProof).toHaveBeenCalledWith( + expect.objectContaining({ id: expect.any(String) }), + "sess-1", + ); + }); +}); + describe("what it mints", () => { it("names exactly the upload scope, and no wildcard", async () => { const res = await POST(req({ name: "Paperless workflow" }) as never); diff --git a/src/app/api/tokens/documents/route.ts b/src/app/api/tokens/documents/route.ts index 215e3676e..c0ca0d9fd 100644 --- a/src/app/api/tokens/documents/route.ts +++ b/src/app/api/tokens/documents/route.ts @@ -23,7 +23,11 @@ */ import { NextRequest } from "next/server"; -import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; +import { + apiHandler, + assertRecentCookieProof, + requireCookieAuth, +} from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -55,7 +59,11 @@ const MINT_RATE_LIMIT_WINDOW_MS = 60 * 1000; const MAX_LIVE_TOKENS = 10; export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireCookieAuth(); + const { user, session } = await requireCookieAuth(); + // And a fresh proof on top of the session: the token lives far longer than + // a stolen session would, so minting one needs the person, not just the + // browser. + await assertRecentCookieProof(user, session.id); annotate({ action: { name: "tokens.documents.create" } }); if (!(await isApiGloballyEnabled())) { diff --git a/src/app/api/tokens/measurements/__tests__/route.test.ts b/src/app/api/tokens/measurements/__tests__/route.test.ts index 21ebe8bfd..3a584dd17 100644 --- a/src/app/api/tokens/measurements/__tests__/route.test.ts +++ b/src/app/api/tokens/measurements/__tests__/route.test.ts @@ -14,6 +14,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/api-handler", () => ({ apiHandler: (fn: unknown) => fn, requireCookieAuth: vi.fn(), + assertRecentCookieProof: vi.fn(), })); vi.mock("@/lib/auth/issue-token", () => ({ @@ -42,7 +43,7 @@ vi.mock("@/lib/db", () => ({ })); import { POST } from "../route"; -import { requireCookieAuth } from "@/lib/api-handler"; +import { assertRecentCookieProof, requireCookieAuth } from "@/lib/api-handler"; import { issueApiToken } from "@/lib/auth/issue-token"; import { isApiGloballyEnabled } from "@/lib/app-settings"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -63,7 +64,11 @@ function req(body: unknown): Request { beforeEach(() => { vi.resetAllMocks(); - vi.mocked(requireCookieAuth).mockResolvedValue({ user: USER } as never); + vi.mocked(requireCookieAuth).mockResolvedValue({ + user: USER, + session: { id: "sess-1" }, + } as never); + vi.mocked(assertRecentCookieProof).mockResolvedValue(undefined); vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); vi.mocked(auditLog).mockResolvedValue(undefined as never); @@ -77,6 +82,26 @@ beforeEach(() => { } as never); }); +describe("the fresh proof in front of the mint", () => { + it("mints nothing when the session carries no recent proof", async () => { + vi.mocked(assertRecentCookieProof).mockRejectedValue( + new Error("auth.reproof.required"), + ); + await expect(POST(req({ name: "Scale" }) as never)).rejects.toThrow( + "auth.reproof.required", + ); + expect(issueApiToken).not.toHaveBeenCalled(); + }); + + it("asks about the caller's own session", async () => { + await POST(req({ name: "Scale" }) as never); + expect(assertRecentCookieProof).toHaveBeenCalledWith( + expect.objectContaining({ id: expect.any(String) }), + "sess-1", + ); + }); +}); + describe("what it mints", () => { it("names exactly the ingest scope, and no wildcard", async () => { const res = await POST(req({ name: "Home Assistant" }) as never); diff --git a/src/app/api/tokens/measurements/route.ts b/src/app/api/tokens/measurements/route.ts index d1c30410b..2b10b3d37 100644 --- a/src/app/api/tokens/measurements/route.ts +++ b/src/app/api/tokens/measurements/route.ts @@ -30,7 +30,11 @@ */ import { NextRequest } from "next/server"; -import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; +import { + apiHandler, + assertRecentCookieProof, + requireCookieAuth, +} from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -94,7 +98,11 @@ export const POST = apiHandler(async (request: NextRequest) => { // front of, and it is the surface this endpoint is reached from anyway. The // same argument the passkey-registration and trusted-device routes make, and // `requireCookieAuth` is the helper they share. - const { user } = await requireCookieAuth(); + const { user, session } = await requireCookieAuth(); + // And a fresh proof on top of the session: the token lives far longer than + // a stolen session would, so minting one needs the person, not just the + // browser. + await assertRecentCookieProof(user, session.id); annotate({ action: { name: "tokens.measurements.create" } }); if (!(await isApiGloballyEnabled())) { diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index b3bde1269..c7f9bd7c5 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -59,6 +59,11 @@ import type { } from "@/lib/export/restore-skips"; import type { BackupRestoreJobView } from "@/lib/jobs/backup-restore"; import { getApiErrorMessage } from "./_shared"; +import { + ReproofCancelledError, + throwIfReproofRequired, + useRecentProof, +} from "@/components/settings/security-section/use-recent-proof"; import { isActiveRestore, RestoreJobStatus, @@ -702,6 +707,57 @@ function ScheduleHealthNotice({ ); } +/** + * Send a backup file to the admin upload route. Sent as the raw body, + * compressed on the way unless it already is: the server reads it as a stream, + * and a large export (hundreds of MB of JSON) shrinks about tenfold. No request + * timeout, because a file of that size takes longer to check and store than + * the default allows. The body is built here, per call, so a retry after a + * re-proof sends the file again rather than a spent stream. + */ +async function uploadOnce(file: File) { + const body = await uploadBody(file); + return apiFetch<{ + id: string; + valid: true; + summary: { + measurements: number; + medications: number; + intakeEvents: number; + moodEntries: number; + // Optional for the same reason as the keys below: a file written + // before side effects rode the wire carries no key at all. + medicationSideEffects?: number; + cycles?: number; + cycleDayLogs?: number; + // v1.28 backup-completeness — optional so an older-schema upload + // (pre-v1.28) still totals correctly without these keys. + labResults?: number; + biomarkers?: number; + illnessEpisodes?: number; + illnessDayLogs?: number; + allergies?: number; + familyHistory?: number; + workouts?: number; + documents?: number; + // Optional for the same reason: a file written before each of these + // rode the wire carries no key, and an absent key must total as + // nothing rather than break the count. + nutrientDays?: number; + healthProfile?: number; + healthProfileFactRevisions?: number; + customMetrics?: number; + customMetricEntries?: number; + intradayProfiles?: number; + }; + }>("/api/admin/backups/upload", { + method: "POST", + body, + headers: { "Content-Type": "application/gzip" }, + signal: null, + }); +} + export function BackupsSection() { const { t } = useTranslations(); const fmt = useFormatters(); @@ -744,52 +800,14 @@ export function BackupsSection() { // Upload state — single-file flow, drives the file input + button. const fileInputRef = useRef(null); + // Download, upload and restore reach every account's data, so each asks for + // a fresh proof unless this session signed in or re-proved within minutes. + const recentProof = useRecentProof(); const upload = useMutation({ mutationFn: async (file: File) => { - // Sent as the raw body, compressed on the way unless it already is: the - // server reads it as a stream, and a large export (hundreds of MB of - // JSON) shrinks about tenfold. No request timeout, because a file of - // that size takes longer to check and store than the default allows. - const body = await uploadBody(file); - return apiFetch<{ - id: string; - valid: true; - summary: { - measurements: number; - medications: number; - intakeEvents: number; - moodEntries: number; - // Optional for the same reason as the keys below: a file written - // before side effects rode the wire carries no key at all. - medicationSideEffects?: number; - cycles?: number; - cycleDayLogs?: number; - // v1.28 backup-completeness — optional so an older-schema upload - // (pre-v1.28) still totals correctly without these keys. - labResults?: number; - biomarkers?: number; - illnessEpisodes?: number; - illnessDayLogs?: number; - allergies?: number; - familyHistory?: number; - workouts?: number; - documents?: number; - // Optional for the same reason: a file written before each of these - // rode the wire carries no key, and an absent key must total as - // nothing rather than break the count. - nutrientDays?: number; - healthProfile?: number; - healthProfileFactRevisions?: number; - customMetrics?: number; - customMetricEntries?: number; - intradayProfiles?: number; - }; - }>("/api/admin/backups/upload", { - method: "POST", - body, - headers: { "Content-Type": "application/gzip" }, - signal: null, - }); + // Ask first, so a large file is not sent once only to be refused. + await recentProof.run(() => apiGet("/api/auth/reproof")); + return uploadOnce(file); }, onSuccess: (data) => { const total = @@ -824,6 +842,7 @@ export function BackupsSection() { queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() }); }, onError: (err) => { + if (err instanceof ReproofCancelledError) return; toast.error( err instanceof Error && err.message ? err.message @@ -938,10 +957,12 @@ export function BackupsSection() { // restore. Include the row id so two different backups can both be // restored independently in the same minute. const idempotencyKey = `restore-${row.id}-${randomId()}`; - return apiPost<{ jobId: string; status: "queued" }>( - `/api/admin/backups/${row.id}/restore`, - { confirm: "RESTORE", restoreInstanceSettings }, - { headers: { "Idempotency-Key": idempotencyKey } }, + return recentProof.run(() => + apiPost<{ jobId: string; status: "queued" }>( + `/api/admin/backups/${row.id}/restore`, + { confirm: "RESTORE", restoreInstanceSettings }, + { headers: { "Idempotency-Key": idempotencyKey } }, + ), ); }, onSuccess: (data) => { @@ -955,6 +976,7 @@ export function BackupsSection() { }); }, onError: (err) => { + if (err instanceof ReproofCancelledError) return; if ( err instanceof ApiError && err.meta?.errorCode === "backup.restore.active" @@ -979,7 +1001,11 @@ export function BackupsSection() { try { // apiFetchRaw: the download needs the raw Response for the blob + // the Content-Disposition filename header. - const res = await apiFetchRaw(`/api/admin/backups/${row.id}/download`); + const res = await recentProof.run(async () => + throwIfReproofRequired( + await apiFetchRaw(`/api/admin/backups/${row.id}/download`), + ), + ); if (!res.ok) { throw new Error(await getApiErrorMessage(res)); } @@ -1002,6 +1028,7 @@ export function BackupsSection() { URL.revokeObjectURL(url); toast.success(t("admin.section.backups.downloadStarted")); } catch (err) { + if (err instanceof ReproofCancelledError) return; toast.error( err instanceof Error && err.message ? err.message @@ -1014,6 +1041,7 @@ export function BackupsSection() { return ( + {recentProof.dialog} {/* v1.18.1 E3 — the snapshot count leads (numbers first) and the "Run now" button follows. */} { // Idempotency-Key prevents a double-submit from re-running the @@ -52,14 +60,16 @@ export function DangerZoneSection() { // used to return nine fixed names, which is how nobody noticed the // wipe had stopped covering the schema: the result line read the same // whether it had cleared nine tables or ninety. - return apiDelete<{ - cleared: boolean; - deletedRows: number; - models: Record; - }>( - "/api/admin/data", - { confirm: WIPE_CONFIRM_TOKEN }, - { headers: { "Idempotency-Key": idempotencyKey } }, + return recentProof.run(() => + apiDelete<{ + cleared: boolean; + deletedRows: number; + models: Record; + }>( + "/api/admin/data", + { confirm: WIPE_CONFIRM_TOKEN }, + { headers: { "Idempotency-Key": idempotencyKey } }, + ), ); }, onSuccess: (data) => { @@ -72,7 +82,7 @@ export function DangerZoneSection() { ); }, onError: (err: Error) => { - setWipeMsg(err.message); + setWipeMsg(recentProofErrorMessage(err, err.message)); }, }); @@ -84,6 +94,7 @@ export function DangerZoneSection() { // are alarm enough. The header keeps the `AlertTriangle` icon so the // section still flags its intent. + {recentProof.dialog} { - await apiPost(`/api/admin/users/${id}/reset-password`, { password }); + await recentProof.run(() => + apiPost(`/api/admin/users/${id}/reset-password`, { password }), + ); }, onSuccess: () => { setResetMsg(t("admin.passwordReset")); setResetPassword(""); }, onError: (err: Error) => { - setResetMsg(err.message); + setResetMsg(recentProofErrorMessage(err, err.message)); }, }); @@ -265,6 +275,7 @@ export function UserManagementSection() { return ( + {recentProof.dialog} (null); const [tokenCopied, setTokenCopied] = useState(false); + // The token outlives the session, so minting one asks for a fresh proof + // unless the session signed in or re-proved within five minutes. + const recentProof = useRecentProof(); async function handleCreate(e: React.FormEvent) { e.preventDefault(); @@ -145,13 +153,18 @@ function IngestTokenCard({ setTokenMsg(null); setNewToken(null); try { - const res = await apiFetchRaw(endpoint, { - method: "POST", - headers: { "Content-Type": "application/json" }, - // No scope field: the endpoint mints one shape, so there is nothing - // here for a caller to choose or for this form to get wrong. - body: JSON.stringify({ name: newName.trim() }), - }); + // No scope field: the endpoint mints one shape, so there is nothing + // here for a caller to choose or for this form to get wrong. + const body = JSON.stringify({ name: newName.trim() }); + const res = await recentProof.run(async () => + throwIfReproofRequired( + await apiFetchRaw(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }), + ), + ); const json = await res.json(); if (res.ok) { setNewToken(json.data.token); @@ -164,8 +177,8 @@ function IngestTokenCard({ // this specific action when it did not. setTokenMsg(json.error || copy.createFailed); } - } catch { - setTokenMsg(t("common.networkError")); + } catch (err) { + setTokenMsg(recentProofErrorMessage(err, t("common.networkError"))); } finally { setCreating(false); } @@ -185,6 +198,7 @@ function IngestTokenCard({ return ( + {recentProof.dialog} { - const res = await apiFetchRaw(url, { credentials: "include" }); + const res = await throwIfReproofRequired( + await apiFetchRaw(url, { credentials: "include" }), + ); if (!res.ok) { throw new Error(`Download failed (${res.status})`); } @@ -418,45 +425,55 @@ function FullBackupCard() { const passphraseTooShort = encrypt && passphrase.length < MIN_EXPORT_PASSPHRASE_LENGTH; + // The whole record leaves the server here, so it asks for a fresh proof + // unless the session signed in or re-proved within five minutes. + const recentProof = useRecentProof(); async function handleDownload() { setBusy(true); setError(null); try { - const stamp = new Date().toISOString().slice(0, 10); - if (encrypt) { - const res = await apiFetchRaw("/api/export/encrypted", { + await recentProof.run(downloadOnce); + } catch (err) { + setError( + recentProofErrorMessage( + err, + t("settings.sections.export.downloadFailed"), + ), + ); + } finally { + setBusy(false); + } + } + + async function downloadOnce() { + const stamp = new Date().toISOString().slice(0, 10); + if (encrypt) { + const res = await throwIfReproofRequired( + await apiFetchRaw("/api/export/encrypted", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ passphrase }), - }); - if (res.status === 401) { - setError(t("settings.sections.export.cards.fullBackup.stepUpNeeded")); - return; - } - if (!res.ok) { - throw new Error(`Download failed (${res.status})`); - } - const blob = await res.blob(); - const objectUrl = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = objectUrl; - a.download = `healthlog-backup-${stamp}.hlx`; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(objectUrl); - } else { - await downloadFromUrl( - "/api/export/full-backup", - `healthlog-backup-${stamp}.json`, - ); + }), + ); + if (!res.ok) { + throw new Error(`Download failed (${res.status})`); } - } catch { - setError(t("settings.sections.export.downloadFailed")); - } finally { - setBusy(false); + const blob = await res.blob(); + const objectUrl = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = objectUrl; + a.download = `healthlog-backup-${stamp}.hlx`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(objectUrl); + } else { + await downloadFromUrl( + "/api/export/full-backup", + `healthlog-backup-${stamp}.json`, + ); } } @@ -550,6 +567,7 @@ function FullBackupCard() { {error}

)} + {recentProof.dialog} ); } diff --git a/src/components/settings/mcp-section.tsx b/src/components/settings/mcp-section.tsx index bbe1c063d..87d5dafc7 100644 --- a/src/components/settings/mcp-section.tsx +++ b/src/components/settings/mcp-section.tsx @@ -50,6 +50,11 @@ import { queryKeys, } from "@/lib/query-keys"; import { apiFetchRaw, apiGet, apiPatch } from "@/lib/api/api-fetch"; +import { + recentProofErrorMessage, + throwIfReproofRequired, + useRecentProof, +} from "@/components/settings/security-section/use-recent-proof"; interface McpTokenInfo { id: string; @@ -289,21 +294,30 @@ function McpTokensCard() { enabled: isAuthenticated, }); + // A connector token outlives the session, so minting one asks for a fresh + // proof unless the session signed in or re-proved within five minutes. + const recentProof = useRecentProof(); + async function handleCreate(e: React.FormEvent) { e.preventDefault(); setCreating(true); setTokenMsg(null); setNewToken(null); try { - const res = await apiFetchRaw("/api/mcp/tokens", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: newName.trim(), - // Closed choice — read-only, or read + the audience-bound write scope. - scope: allowWrite ? "read_write" : "read", - }), + const body = JSON.stringify({ + name: newName.trim(), + // Closed choice — read-only, or read + the audience-bound write scope. + scope: allowWrite ? "read_write" : "read", }); + const res = await recentProof.run(async () => + throwIfReproofRequired( + await apiFetchRaw("/api/mcp/tokens", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }), + ), + ); const json = await res.json(); if (res.ok) { setNewToken(json.data.token); @@ -313,8 +327,8 @@ function McpTokensCard() { } else { setTokenMsg(json.error || t("common.error")); } - } catch { - setTokenMsg(t("common.networkError")); + } catch (err) { + setTokenMsg(recentProofErrorMessage(err, t("common.networkError"))); } finally { setCreating(false); } @@ -351,6 +365,7 @@ function McpTokensCard() { return ( + {recentProof.dialog} readFileSync(resolve(SRC, file), "utf8"); + +describe("throwIfReproofRequired", () => { + it("turns the refusal into an ApiError carrying the methods", async () => { + const res = new Response( + JSON.stringify({ + data: null, + error: "Confirm it is you to continue", + meta: { errorCode: "auth.reproof.required", methods: ["password"] }, + }), + { status: 401 }, + ); + const err = await throwIfReproofRequired(res).catch((e: unknown) => e); + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).meta?.methods).toEqual(["password"]); + }); + + it("passes every other response through untouched", async () => { + const other = new Response( + JSON.stringify({ meta: { errorCode: "auth.stepup.required" } }), + { status: 401 }, + ); + await expect(throwIfReproofRequired(other)).resolves.toBe(other); + const ok = new Response("{}", { status: 200 }); + await expect(throwIfReproofRequired(ok)).resolves.toBe(ok); + }); +}); + +describe("recentProofErrorMessage", () => { + it("shows nothing for a cancelled dialog and the sentence for sign-in-again", () => { + expect(recentProofErrorMessage(new ReproofCancelledError(), "x")).toBe( + null, + ); + expect( + recentProofErrorMessage( + new ApiError("Sign in again", 401, { + errorCode: "auth.reproof.sign_in_again", + }), + "x", + ), + ).toBe("Sign in again"); + expect(recentProofErrorMessage(new Error("boom"), "fallback")).toBe( + "fallback", + ); + }); +}); + +describe.each([ + ["components/settings/export-section.tsx", "/api/export/full-backup"], + ["components/settings/export-section.tsx", "/api/export/encrypted"], + ["components/settings/share-link-create-form.tsx", "/api/share-links"], + ["components/settings/mcp-section.tsx", "/api/mcp/tokens"], + ["components/settings/api-section.tsx", "/api/tokens/measurements"], + ["components/admin/backups-section.tsx", "/download"], + ["components/admin/backups-section.tsx", "/restore"], + ["components/admin/backups-section.tsx", "/api/admin/backups/upload"], + ["components/admin/danger-zone-section.tsx", "/api/admin/data"], + ["components/admin/user-management-section.tsx", "/reset-password"], +])("%s → %s", (file, endpoint) => { + const source = read(file); + + it("calls the gated route", () => { + expect(source).toContain(endpoint); + }); + + it("runs it through the recent-proof hook and renders the dialog", () => { + expect(source).toContain("useRecentProof()"); + expect(source).toMatch(/recentProof\.run\(/); + expect(source).toContain("{recentProof.dialog}"); + }); +}); diff --git a/src/components/settings/security-section/existing-factor-reauth-dialog.tsx b/src/components/settings/security-section/existing-factor-reauth-dialog.tsx index 63288ce37..88fd9c3b7 100644 --- a/src/components/settings/security-section/existing-factor-reauth-dialog.tsx +++ b/src/components/settings/security-section/existing-factor-reauth-dialog.tsx @@ -58,7 +58,10 @@ export function ExistingFactorReauthDialog({ pending, error, onProof, + description, }: { + /** Overrides the default "before adding a second factor" sentence. */ + description?: string; open: boolean; onOpenChange: (open: boolean) => void; methods: ReauthMethod[]; @@ -134,7 +137,7 @@ export function ExistingFactorReauthDialog({ {t("settings.passkeyReauth.title")} - {t("settings.passkeyReauth.factorDescription")} + {description ?? t("settings.passkeyReauth.factorDescription")} diff --git a/src/components/settings/security-section/use-recent-proof.tsx b/src/components/settings/security-section/use-recent-proof.tsx new file mode 100644 index 000000000..e796f27fe --- /dev/null +++ b/src/components/settings/security-section/use-recent-proof.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useCallback, useRef, useState, type ReactNode } from "react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { ApiError, apiPost } from "@/lib/api/api-fetch"; +import { + ExistingFactorReauthDialog, + isReproofRequired, + type ExistingFactorProof, + type ReauthMethod, +} from "./existing-factor-reauth-dialog"; + +const KNOWN_METHODS: readonly ReauthMethod[] = [ + "password", + "totp", + "webauthn", + "passkey", +]; + +/** The proofs a `auth.reproof.required` refusal says this account can give. */ +function methodsFrom(err: ApiError): ReauthMethod[] { + const raw = err.meta?.methods; + if (!Array.isArray(raw)) return []; + return raw.filter((m): m is ReauthMethod => + KNOWN_METHODS.includes(m as ReauthMethod), + ); +} + +/** The person closed the re-proof dialog. Nothing ran; show nothing. */ +export class ReproofCancelledError extends Error { + constructor() { + super("Re-proof cancelled"); + this.name = "ReproofCancelledError"; + } +} + +/** + * The sentence to show for an error `run` rejected with, or null when there is + * nothing to show (the dialog was cancelled). Any other error returns + * `fallback`. + */ +export function recentProofErrorMessage( + err: unknown, + fallback: string, +): string | null { + if (err instanceof ReproofCancelledError) return null; + if ( + err instanceof ApiError && + err.meta?.errorCode === "auth.reproof.sign_in_again" + ) { + return err.message; + } + return fallback; +} + +/** + * Turn a raw `Response` from a download or upload call site into the + * `ApiError` the hook recognises, when it is the recent-proof refusal. Any + * other response is returned untouched for the caller's own handling. + */ +export async function throwIfReproofRequired(res: Response): Promise { + if (res.status !== 401) return res; + let body: { error?: unknown; meta?: unknown } | null = null; + try { + body = await res.clone().json(); + } catch { + return res; + } + const meta = + body && typeof body.meta === "object" && body.meta !== null + ? (body.meta as Record) + : undefined; + const err = new ApiError( + typeof body?.error === "string" ? body.error : "", + 401, + meta, + ); + if (isReproofRequired(err)) throw err; + return res; +} + +/** + * Run an action that the server may hold back for a fresh proof. + * + * Exports of the whole record, share links, token minting and the admin data + * actions answer 401 `auth.reproof.required` with `meta.methods` when the + * session has not signed in or re-proved within five minutes. `run` catches + * exactly that refusal, opens the re-proof dialog with the listed methods, + * posts the proof to `/api/auth/reproof` and runs the action once more. Every + * other error rejects as usual; a cancelled dialog rejects with + * `ReproofCancelledError`. `recentProofErrorMessage` maps both for display. + * + * An account with nothing to re-prove in place (signed in through single + * sign-on only) gets no dialog: the refusal is rethrown with a message asking + * to sign in again. + */ +export function useRecentProof(): { + run: (action: () => Promise) => Promise; + dialog: ReactNode; +} { + const { t } = useTranslations(); + const [methods, setMethods] = useState(null); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const waiter = useRef<{ + resolve: () => void; + reject: () => void; + } | null>(null); + + const run = useCallback( + async (action: () => Promise): Promise => { + try { + return await action(); + } catch (err) { + if (!isReproofRequired(err)) throw err; + const offered = methodsFrom(err as ApiError); + if (offered.length === 0) { + throw new ApiError(t("settings.reproof.signInAgain"), 401, { + errorCode: "auth.reproof.sign_in_again", + }); + } + await new Promise((resolve, reject) => { + waiter.current = { + resolve, + reject: () => reject(new ReproofCancelledError()), + }; + setError(null); + setMethods(offered); + }); + return action(); + } + }, + [t], + ); + + const onProof = useCallback( + async (proof: ExistingFactorProof) => { + setPending(true); + setError(null); + try { + await apiPost("/api/auth/reproof", proof); + setMethods(null); + const w = waiter.current; + waiter.current = null; + w?.resolve(); + } catch (err) { + setError( + err instanceof ApiError && err.status === 429 + ? err.message + : t("settings.reproof.failed"), + ); + } finally { + setPending(false); + } + }, + [t], + ); + + const dialog = ( + { + if (open) return; + setMethods(null); + setError(null); + const w = waiter.current; + waiter.current = null; + w?.reject(); + }} + methods={methods ?? ["password"]} + pending={pending} + error={error} + onProof={(proof) => void onProof(proof)} + description={t("settings.reproof.description")} + /> + ); + + return { run, dialog }; +} diff --git a/src/components/settings/share-link-create-form.tsx b/src/components/settings/share-link-create-form.tsx index 1db55ba00..90e6cd695 100644 --- a/src/components/settings/share-link-create-form.tsx +++ b/src/components/settings/share-link-create-form.tsx @@ -40,6 +40,10 @@ import { orderLeaves } from "@/lib/report-selection/selection"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiPost } from "@/lib/api/api-fetch"; +import { + recentProofErrorMessage, + useRecentProof, +} from "@/components/settings/security-section/use-recent-proof"; /** Maximum lifetime, in days — mirrors `SHARE_LINK_MAX_DAYS` on the server. */ export const MAX_DAYS = 90; @@ -240,6 +244,10 @@ export function ShareLinkCreateForm({ }; }, [qrUrl]); + // A share link opens the record without signing in, so making one asks for + // a fresh proof unless the session signed in or re-proved within minutes. + const recentProof = useRecentProof(); + const createMutation = useMutation({ mutationFn: () => { // Surface the same expiry-bound the server enforces before the round @@ -247,16 +255,16 @@ export function ShareLinkCreateForm({ if (expiryDays < 1 || expiryDays > MAX_DAYS) { return Promise.reject(new Error("EXPIRY_RANGE")); } - return apiPost( - "/api/share-links", - buildShareLinkCreatePayload({ - label, - rangeDays, - expiryDays, - leaves: orderLeaves(selectedLeaves), - documentIds: selectedDocs.map((d) => d.id), - documentOnly, - }), + const payload = buildShareLinkCreatePayload({ + label, + rangeDays, + expiryDays, + leaves: orderLeaves(selectedLeaves), + documentIds: selectedDocs.map((d) => d.id), + documentOnly, + }); + return recentProof.run(() => + apiPost("/api/share-links", payload), ); }, onSuccess: (result) => { @@ -277,7 +285,7 @@ export function ShareLinkCreateForm({ setFormError( err.message === "EXPIRY_RANGE" ? t("settings.sharing.expiryInvalid", { max: MAX_DAYS }) - : t("common.error"), + : recentProofErrorMessage(err, t("common.error")), ); }, }); @@ -294,6 +302,7 @@ export function ShareLinkCreateForm({ return ( <> + {recentProof.dialog}
{ diff --git a/src/lib/__tests__/require-recent-proof.test.ts b/src/lib/__tests__/require-recent-proof.test.ts new file mode 100644 index 000000000..5f3716fb2 --- /dev/null +++ b/src/lib/__tests__/require-recent-proof.test.ts @@ -0,0 +1,221 @@ +/** + * `requireRecentProof` / `assertRecentCookieProof` — the fresh-proof gate in + * front of whole-record exports, share links, token minting and the admin data + * actions. + * + * Cookie: an account with a second factor needs `mfaVerifiedAt` inside five + * minutes and nothing else; one without accepts a recent sign-in, second-factor + * stamp or password re-proof. Bearer: the rule the route names. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + session: { findUnique: vi.fn() }, + apiToken: { findUnique: vi.fn(), update: vi.fn() }, + user: { findUnique: vi.fn() }, + webauthnMfaCredential: { count: vi.fn() }, + passkey: { count: vi.fn() }, + stepUpElevation: { findUnique: vi.fn() }, + $queryRaw: vi.fn(), + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn(() => "hash") })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +const headersGet = vi.fn<(name: string) => string | null>(); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: headersGet })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { requireRecentProof, StepUpRequiredError } from "../api-handler"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { headers } from "next/headers"; +import { auditLog } from "@/lib/auth/audit"; + +const MFA_USER = { + id: "user-1", + role: "USER" as const, + username: "u", + totpConfirmedAt: new Date("2020-01-01"), + passwordHash: "argon", +}; +const PLAIN_USER = { ...MFA_USER, totpConfirmedAt: null }; + +const NOW = () => new Date(); +const OLD = () => new Date(Date.now() - 10 * 60 * 1000); + +beforeEach(() => { + vi.resetAllMocks(); + headersGet.mockReturnValue(null); + vi.mocked(headers).mockImplementation( + async () => ({ get: headersGet }) as never, + ); + vi.mocked(auditLog).mockResolvedValue(undefined); + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never); + vi.mocked(prisma.passkey.count).mockResolvedValue(0 as never); +}); + +function cookie( + user: unknown, + stamps: { + mfaVerifiedAt?: Date | null; + createdAt?: Date; + reproofAt?: Date | null; + }, +) { + vi.mocked(getSession).mockResolvedValue({ + session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) }, + user, + } as never); + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + mfaVerifiedAt: stamps.mfaVerifiedAt ?? null, + createdAt: stamps.createdAt ?? OLD(), + reproofAt: stamps.reproofAt ?? null, + } as never); +} + +function bearer(user: unknown, elevation: string | null = null) { + vi.mocked(getSession).mockResolvedValue(null as never); + headersGet.mockImplementation((n) => { + const name = n.toLowerCase(); + if (name === "authorization") return "Bearer hlk_xyz"; + if (name === "x-step-up") return elevation; + return null; + }); + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "tok-1", + userId: "user-1", + permissions: ["*"], + revoked: false, + expiresAt: null, + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(user as never); + vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never); +} + +async function refusal(p: Promise): Promise { + try { + await p; + } catch (err) { + expect(err).toBeInstanceOf(StepUpRequiredError); + return err as StepUpRequiredError; + } + throw new Error("expected a refusal"); +} + +describe("cookie session, account without a second factor", () => { + it("passes on a sign-in inside five minutes", async () => { + cookie(PLAIN_USER, { createdAt: NOW() }); + await expect( + requireRecentProof({ bearer: "elevation" }), + ).resolves.toBeTruthy(); + }); + + it("passes on a password re-proof inside five minutes", async () => { + cookie(PLAIN_USER, { reproofAt: NOW() }); + await expect( + requireRecentProof({ bearer: "elevation" }), + ).resolves.toBeTruthy(); + }); + + it("refuses an old session and names the password as the way through", async () => { + cookie(PLAIN_USER, {}); + const err = await refusal(requireRecentProof({ bearer: "token" })); + expect(err.errorCode).toBe("auth.reproof.required"); + expect(err.extraMeta).toEqual({ methods: ["password"] }); + }); + + it("offers a passkey when the account holds one", async () => { + cookie({ ...PLAIN_USER, passwordHash: null }, {}); + vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never); + const err = await refusal(requireRecentProof({ bearer: "token" })); + expect(err.extraMeta).toEqual({ methods: ["passkey"] }); + }); +}); + +describe("cookie session, account with a second factor", () => { + it("passes on a second factor inside five minutes", async () => { + cookie(MFA_USER, { mfaVerifiedAt: NOW() }); + await expect( + requireRecentProof({ bearer: "elevation" }), + ).resolves.toBeTruthy(); + }); + + it("a password re-proof does not stand in for the second factor", async () => { + cookie(MFA_USER, { reproofAt: NOW() }); + const err = await refusal(requireRecentProof({ bearer: "elevation" })); + expect(err.extraMeta).toEqual({ methods: ["totp"] }); + }); + + it("nor does a fresh session without the factor (a remembered device)", async () => { + cookie(MFA_USER, { createdAt: NOW() }); + await refusal(requireRecentProof({ bearer: "elevation" })); + }); + + it("refuses a stale second-factor stamp", async () => { + cookie(MFA_USER, { mfaVerifiedAt: OLD() }); + await refusal(requireRecentProof({ bearer: "elevation" })); + }); +}); + +describe("Bearer", () => { + it("`elevation` refuses a token without one", async () => { + bearer(PLAIN_USER); + const err = await refusal(requireRecentProof({ bearer: "elevation" })); + expect(err.errorCode).toBe("auth.stepup.required"); + }); + + it("`elevation-if-enrolled` passes a token on an account without a second factor", async () => { + bearer(PLAIN_USER); + await expect( + requireRecentProof({ bearer: "elevation-if-enrolled" }), + ).resolves.toBeTruthy(); + }); + + it("`elevation-if-enrolled` refuses a bare token on an enrolled account", async () => { + bearer(MFA_USER); + await refusal(requireRecentProof({ bearer: "elevation-if-enrolled" })); + }); + + it("`token` passes on the token alone", async () => { + bearer(MFA_USER); + await expect(requireRecentProof({ bearer: "token" })).resolves.toBeTruthy(); + }); + + it("`elevation` accepts a password-proved elevation on an account without a second factor", async () => { + bearer(PLAIN_USER, `hle_${"a".repeat(64)}`); + vi.mocked(prisma.stepUpElevation.findUnique).mockResolvedValue({ + userId: "user-1", + apiTokenId: "tok-1", + consumedAt: null, + expiresAt: new Date(Date.now() + 60_000), + method: "password", + } as never); + const auth = await requireRecentProof({ bearer: "elevation" }); + expect(typeof auth.commitElevation).toBe("function"); + }); + + it("`elevation` refuses a password-proved elevation on an enrolled account", async () => { + bearer(MFA_USER, `hle_${"a".repeat(64)}`); + vi.mocked(prisma.stepUpElevation.findUnique).mockResolvedValue({ + userId: "user-1", + apiTokenId: "tok-1", + consumedAt: null, + expiresAt: new Date(Date.now() + 60_000), + method: "password", + } as never); + await refusal(requireRecentProof({ bearer: "elevation" })); + }); +}); diff --git a/src/lib/api-errors.ts b/src/lib/api-errors.ts index 29aaf9a22..d891d3af1 100644 --- a/src/lib/api-errors.ts +++ b/src/lib/api-errors.ts @@ -153,6 +153,13 @@ export class RecordSessionChangedError extends SharingAuthError { } } +/** + * The session carries no proof recent enough for the action, and the request + * brought none. The client asks for one (`POST /api/auth/reproof`, or a proof + * in the body on the enrollment routes) and retries. + */ +export const REPROOF_REQUIRED_CODE = "auth.reproof.required"; + /** * Error thrown when a step-up gate is not satisfied. Carries `errorCode` so * the route can surface a stable machine code (`auth.stepup.required`) the @@ -163,6 +170,13 @@ export class StepUpRequiredError extends HttpError { constructor( public errorCode: string = "auth.stepup.required", message = "Recent second-factor verification required", + /** + * Extra machine fields for the envelope's `meta`, beside `errorCode`. The + * recent-proof gate names the proofs the account can give here + * (`methods`), so the client offers exactly those and nothing it would + * then refuse. + */ + public extraMeta: Record = {}, ) { super(401, message); this.name = "StepUpRequiredError"; diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 37c0c1863..db1087f5a 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -93,6 +93,7 @@ import { SharingNotPermittedError, RecordSessionChangedError, StepUpRequiredError, + REPROOF_REQUIRED_CODE, } from "./api-errors"; export { @@ -289,7 +290,7 @@ export function apiHandler Promise>( { data: null, error: error.message, - meta: { errorCode: error.errorCode }, + meta: { ...error.extraMeta, errorCode: error.errorCode }, }, { status: error.statusCode }, ); @@ -1387,6 +1388,151 @@ export async function requireFreshMfaOrElevationIfEnrolled( return { ...auth, commitElevation }; } +/** + * A proof the recent-proof gate can ask for. `password` and `passkey` on an + * account without a second factor; `totp`, `webauthn` and `passkey` on one + * with a second factor, where a password alone has never been enough. + */ +export type RecentProofMethod = "password" | "totp" | "webauthn" | "passkey"; + +/** + * How the recent-proof gate treats a Bearer caller. + * + * `elevation` — the Bearer twin of the cookie rule: an `X-Step-Up` elevation + * minted by `POST /api/auth/step-up`. On an account with a second factor it + * must come from a second-factor proof; without one, a password-proved + * elevation is enough. For routes no shipped client calls on Bearer. + * + * `elevation-if-enrolled` — the erasure routes' rule + * (`requireFreshMfaOrElevationIfEnrolled`): an account with a second factor + * presents a second-factor elevation, one without passes on the token. For a + * route the shipped app calls, where the second-factor arm was already + * refused before and the other arm must keep working. + * + * `token` — the token is the proof, as before. Only for routes the shipped + * app calls without an elevation, where a refusal would sign the app out (it + * reads a 401 outside its known paths as a dead session). Each one is named + * in the iOS note and in `recent-proof-surface-guard.test.ts`, so tightening + * it later is a visible edit. + */ +export type RecentProofBearerRule = + "elevation" | "elevation-if-enrolled" | "token"; + +/** + * The fresh-proof gate in front of the actions that hand the record, or a + * standing credential to it, to somebody: exporting the whole record, making a + * clinician share link, minting a token, and the admin backup, restore, wipe + * and reset actions. A live session is not enough for those, because whoever + * holds a stolen session would otherwise walk off with the record or leave a + * credential behind that outlives the session. + * + * Cookie session, account WITH a second factor: `Session.mfaVerifiedAt` + * within five minutes — exactly `requireFreshMfa`. Signing in completes the + * factor; later, `POST /api/auth/reproof` with a TOTP code, security key or + * passkey stamps it again. + * + * Cookie session, account WITHOUT a second factor: any of the session's + * creation (a sign-in), `mfaVerifiedAt` (a passkey sign-in or re-proof) or + * `reproofAt` (a password re-proof) within five minutes. Such an account + * re-enters its password, or uses a passkey. + * + * The refusal is 401 `auth.reproof.required` with `meta.methods`, the proofs + * this account can give; the web opens the re-proof dialog on it and retries. + * An empty list means the account holds nothing to re-prove in place (an + * SSO-only account), and signing in again is the way through. + * + * Returns the caller plus `commitElevation`, which spends a Bearer elevation + * and is a no-op otherwise. Call it right before acting. + */ +export async function requireRecentProof(options: { + bearer: RecentProofBearerRule; +}): Promise { + const auth = await requireAuth(); + const noop = async () => {}; + + if (auth.authMethod === "cookie") { + await assertRecentCookieProof(auth.user, auth.session.id); + return { ...auth, commitElevation: noop }; + } + + const enrolled = await hasSecondFactorEnrolled(auth.user); + if (options.bearer === "token") { + annotate({ meta: { recent_proof: "bearer_token" } }); + return { ...auth, commitElevation: noop }; + } + if (options.bearer === "elevation-if-enrolled" && !enrolled) { + annotate({ meta: { recent_proof: "bearer_token" } }); + return { ...auth, commitElevation: noop }; + } + const commitElevation = await resolveBearerElevation( + { user: auth.user, apiTokenId: auth.session.id }, + { freshFactor: enrolled, proofSource: "second-factor" }, + ); + return { ...auth, commitElevation }; +} + +/** + * The cookie arm of {@link requireRecentProof}, for routes that resolve the + * caller themselves (the admin routes, which must stay on `requireAdmin`). + */ +export async function assertRecentCookieProof( + user: User, + sessionId: string, +): Promise { + const enrolled = await hasSecondFactorEnrolled(user); + const row = await prisma.session.findUnique({ + where: { id: sessionId }, + select: { mfaVerifiedAt: true, createdAt: true, reproofAt: true }, + }); + const now = Date.now(); + const fresh = (d: Date | null | undefined) => + d != null && now - d.getTime() <= MFA_STEP_UP_MAX_AGE_SECONDS * 1000; + const satisfied = enrolled + ? fresh(row?.mfaVerifiedAt) + : fresh(row?.mfaVerifiedAt) || + fresh(row?.createdAt) || + fresh(row?.reproofAt); + if (satisfied) { + annotate({ meta: { recent_proof: "session" } }); + return; + } + const methods = await recentProofMethods(user, enrolled); + annotate({ + action: { name: "auth.reproof.required" }, + meta: { second_factor: enrolled, methods: methods.join(",") }, + }); + throw new StepUpRequiredError( + REPROOF_REQUIRED_CODE, + "Confirm it is you to continue", + { methods }, + ); +} + +/** + * The proofs that would satisfy {@link assertRecentCookieProof} for this + * account. Also what `POST /api/auth/reproof` accepts, so the dialog and the + * endpoint cannot disagree. + */ +export async function recentProofMethods( + user: Pick, + enrolled?: boolean, +): Promise { + const hasSecond = enrolled ?? (await hasSecondFactorEnrolled(user)); + const [keys, passkeys] = await Promise.all([ + prisma.webauthnMfaCredential.count({ where: { userId: user.id } }), + prisma.passkey.count({ where: { userId: user.id } }), + ]); + const methods: RecentProofMethod[] = []; + if (hasSecond) { + if (user.totpConfirmedAt) methods.push("totp"); + if (keys > 0) methods.push("webauthn"); + } else if (user.passwordHash) { + methods.push("password"); + } + if (passkeys > 0) methods.push("passkey"); + return methods; +} + /** * Does this account have a SECOND FACTOR at all? * diff --git a/src/lib/auth/existing-factor-proof.ts b/src/lib/auth/existing-factor-proof.ts index 64a9c3060..64915503d 100644 --- a/src/lib/auth/existing-factor-proof.ts +++ b/src/lib/auth/existing-factor-proof.ts @@ -19,6 +19,7 @@ import type { User } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { apiError } from "@/lib/api-response"; +import { REPROOF_REQUIRED_CODE } from "@/lib/api-errors"; import { auditLog } from "@/lib/auth/audit"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -164,7 +165,7 @@ export async function recordReproofFailure( } /** No proof was presented, and the session carries none recent enough. */ -export const REPROOF_REQUIRED_CODE = "auth.reproof.required"; +export { REPROOF_REQUIRED_CODE }; /** A proof was presented and did not verify. The session is untouched. */ export const REPROOF_FAILED_CODE = "auth.reproof.failed"; @@ -188,6 +189,9 @@ export const RECENT_PROOF_MAX_AGE_MS = 5 * 60 * 1000; * * - the session completed a second factor or passkey sign-in within the last * five minutes (`Session.mfaVerifiedAt`); + * - the session re-proved a credential at `POST /api/auth/reproof` within + * the last five minutes (`Session.reproofAt`), which accepts the same + * proofs this gate takes in the body; * - the session itself was created by a sign-in within the last five * minutes (`Session.createdAt`, which no later write touches) — this is * also what lets an SSO-only account with nothing else to re-prove enroll @@ -210,12 +214,16 @@ export async function checkCookieEnrollmentProof(args: { const row = await prisma.session.findUnique({ where: { id: sessionId }, - select: { mfaVerifiedAt: true, createdAt: true }, + select: { mfaVerifiedAt: true, createdAt: true, reproofAt: true }, }); const now = Date.now(); const recent = (d: Date | null | undefined) => d != null && now - d.getTime() <= RECENT_PROOF_MAX_AGE_MS; - if (recent(row?.mfaVerifiedAt) || recent(row?.createdAt)) { + if ( + recent(row?.mfaVerifiedAt) || + recent(row?.createdAt) || + recent(row?.reproofAt) + ) { annotate({ meta: { enrollment_proof: "recent_session" } }); return null; } @@ -316,12 +324,17 @@ export async function authorizeSensitiveChange(args: { if (cookieSessionId) { const row = await prisma.session.findUnique({ where: { id: cookieSessionId }, - select: { mfaVerifiedAt: true, createdAt: true }, + select: { mfaVerifiedAt: true, createdAt: true, reproofAt: true }, }); const now = Date.now(); const recent = (d: Date | null | undefined) => d != null && now - d.getTime() <= RECENT_PROOF_MAX_AGE_MS; - if (recent(row?.mfaVerifiedAt) || recent(row?.createdAt)) return "ok"; + if ( + recent(row?.mfaVerifiedAt) || + recent(row?.createdAt) || + recent(row?.reproofAt) + ) + return "ok"; } if (!currentPassword) return "required"; diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 259d519ea..7d58ece8e 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -62,6 +62,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "auth.refresh.revoked", "auth.reproof.failed", "auth.reproof.required", + "auth.reproof.too_weak", "auth.scope.insufficient", "auth.stepup.required", "auth.token.expired", diff --git a/src/lib/openapi/routes/admin-backups.ts b/src/lib/openapi/routes/admin-backups.ts index c86a51dff..44edd3fdc 100644 --- a/src/lib/openapi/routes/admin-backups.ts +++ b/src/lib/openapi/routes/admin-backups.ts @@ -170,7 +170,7 @@ export const adminBackupPaths: NonNullable = { tags: ["Admin"], summary: "Download one stored backup as JSON", description: - "Decrypts one stored copy and returns the backup document itself, with `Content-Disposition: attachment` and `Cache-Control: no-store`. The response is the raw document, not the standard envelope — it is the artefact an operator keeps. The ciphertext stays in the database. Admin session cookie required; Bearer tokens cannot reach admin endpoints.", + "Decrypts one stored copy and returns the backup document itself, with `Content-Disposition: attachment` and `Cache-Control: no-store`. The response is the raw document, not the standard envelope — it is the artefact an operator keeps. The ciphertext stays in the database. Admin session cookie required; Bearer tokens cannot reach admin endpoints. 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.", requestParams: { path: z.object({ id: z.string().meta({ description: "`DataBackup.id`." }), @@ -206,7 +206,7 @@ export const adminBackupPaths: NonNullable = { tags: ["Admin"], summary: "Queue the restore of one stored backup over its owner's record", description: - "Queues a restore that replaces the snapshot owner's data tables from the stored copy, in one transaction, under the ids the file carries, and answers 202 with the job's id. Replacing is not merging: every row the account gained after the snapshot was taken is deleted with the rest of its class. The target is the account the snapshot was taken for, never the admin running it. The request refuses a missing confirmation, an unknown backup and a copy that cannot be decrypted; the file's own checks (schema, declared owner, manifest, documents) run in the job before anything is deleted, and a refusal there ends the job as `failed` with the same reason. One restore per account at a time. Admin session cookie required; Bearer tokens cannot reach admin endpoints.", + "Queues a restore that replaces the snapshot owner's data tables from the stored copy, in one transaction, under the ids the file carries, and answers 202 with the job's id. Replacing is not merging: every row the account gained after the snapshot was taken is deleted with the rest of its class. The target is the account the snapshot was taken for, never the admin running it. The request refuses a missing confirmation, an unknown backup and a copy that cannot be decrypted; the file's own checks (schema, declared owner, manifest, documents) run in the job before anything is deleted, and a refusal there ends the job as `failed` with the same reason. One restore per account at a time. Admin session cookie required; Bearer tokens cannot reach admin endpoints. 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.", requestParams: { path: z.object({ id: z.string().meta({ description: "`DataBackup.id`." }), diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 7e3885c78..f0d1c2341 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -817,7 +817,7 @@ export const authPaths: NonNullable = { "WHICH factor you re-prove decides WHAT the elevation reaches. `password` reaches the same routes a plain cookie session reaches. `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes — MFA disable, recovery-code regeneration, security-key removal — which is precisely the set of ceremonies for which the web marks a session second-factor-verified. The response carries `satisfiesFreshFactor` so a client can choose the right ceremony up front rather than discovering the refusal after spending a proof. A recovery code is NOT accepted here; an account that has lost its authenticator manages its second factor on the web.\n\n" + "The elevation is bound to the exact token that minted it (another token, including the same account's, cannot redeem it), single-use, and valid for five minutes — the same window the cookie path uses. Present it as `X-Step-Up: hle_…` alongside the normal `Authorization: Bearer` header. It is spent only when the target route is about to act, so a 429, a 422, or a wrong code does not burn it.\n\n" + "Presenting the token alone mints nothing: the body must carry a fresh factor proof. Every failure — wrong password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge, a replayed TOTP step — returns the same 401 with the same prose, and is audited server-side. Rate-limited per source address and per account (5 / 15 min); the per-account budget is shared with the web's credential-enrollment proofs. A `totp` proof counts only from an authenticator whose enrollment was confirmed: a code from a pending secret is refused like a wrong one.\n\n" + - "Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE /api/auth/me/mfa/webauthn/{id}; DELETE /api/settings/account; DELETE /api/settings/data. The last three MFA routes, disable, and both erasure routes require a fresh-factor proof. GET /api/auth/me/mfa needs no elevation at all. Nothing else accepts one — admin endpoints stay cookie-only.\n\n" + + "Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE /api/auth/me/mfa/webauthn/{id}; DELETE /api/settings/account; DELETE /api/settings/data; GET /api/export/full-backup; POST /api/export/encrypted. The last three MFA routes, disable, and both erasure routes require a fresh-factor proof. The two whole-record exports take any elevation on an account without a second factor and a fresh-factor one on an account with one; the full backup requires an elevation on every Bearer call, the encrypted export only on an account with a second factor. GET /api/auth/me/mfa needs no elevation at all. Nothing else accepts one — admin endpoints stay cookie-only.\n\n" + "The two erasure routes joined the set so an account with a second factor can be deleted from a native app: their gate was cookie-only, which left an enrolled user unable to delete their own account from the app at all. They take the elevation the same way as the rest — fresh factor required, spent only when the erasure is about to run.", requestBody: { required: true, @@ -836,6 +836,61 @@ export const authPaths: NonNullable = { }, }, }, + "/api/auth/reproof": { + post: { + tags: ["Auth"], + summary: "Re-prove a credential on a browser session (cookie only)", + description: + "Stamps the calling browser session as recently re-proved, so for the next five minutes it may run the actions that ask for a fresh proof: GET /api/export/full-backup, POST /api/export/encrypted, POST /api/share-links, POST /api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/documents, and the admin backup download, upload and restore, the admin data wipe and the admin password reset. Those answer 401 `auth.reproof.required` with `meta.methods` (the proofs this account can give) when the session signed in or re-proved more than five minutes ago.\n\n" + + 'The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`, `webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second factor; on one without, `password` or `passkey`. A method the account cannot use here is refused with 422 `auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also refreshes the session\'s second-factor stamp, exactly as signing in again would. Assertions begin at POST /api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`.\n\n' + + "Proofs draw on the account's shared re-proof budget (five per fifteen minutes, 429 once spent). A proof that does not verify is 401 `auth.reproof.failed` and is audited. Bearer callers use POST /api/auth/step-up instead.", + requestBody: { + required: true, + content: { "application/json": { schema: stepUpMintSchema } }, + }, + responses: { + "200": { + description: "Proof accepted; the session is stamped.", + content: { + "application/json": { + schema: dataEnvelope( + z + .object({ + method: z.enum(["password", "totp", "webauthn", "passkey"]), + verifiedAt: z.string().datetime(), + }) + .meta({ id: "ReproofResponse" }), + "ReproofEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + get: { + tags: ["Auth"], + summary: "Ask whether this browser session's proof is recent", + description: + "The question the gated routes ask, without acting: 200 `{ recent: true }` when the session signed in or re-proved within five minutes (a second factor on an account that has one), otherwise 401 `auth.reproof.required` with `meta.methods`. Lets a client re-prove before sending a large upload rather than after it was refused.", + responses: { + "200": { + description: "The session's proof is recent.", + content: { + "application/json": { + schema: dataEnvelope( + z + .object({ recent: z.literal(true) }) + .meta({ id: "ReproofStatus" }), + "ReproofStatusEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/auth/me/mfa/totp/setup": { post: { tags: ["Auth"], @@ -1609,7 +1664,7 @@ export const authPaths: NonNullable = { "Mints a Bearer scoped to exactly `measurements:write` and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN — the only place it ever exists; it is stored as an HMAC and no path re-reveals it.\n\n" + "**What it can do.** `POST /api/measurements` and `POST /api/measurements/batch`, on its owner's own record. Rows it writes carry `source: EXTERNAL`, resolved from the credential rather than taken from the body, so a reading pushed by a bridge stays distinguishable from one typed in by hand and can be picked out with the source filter. A body naming ANY source is refused 422 rather than relabelled — honouring it would defeat the label, overriding it silently would hand the caller rows it did not ask for. `APPLE_HEALTH` is the one that would do real damage: it is half a dedup key the phone also writes into, it participates in the cross-source merge, and it is what decides the Apple Health card may claim a sync happened. For the same reason a write through this credential does not move the native client's sync checkpoint. The rows stay editable by their owner, unlike a connected provider's — the hardware behind the token is the user's own.\n\n" + "**What it cannot do.** Everything else, including the measurement reads on the same paths, the edit and delete legs, and the export — a scope grants what it names and nothing adjacent. It cannot be pointed at a shared record: a request carrying the account selector is refused 403 before any grant is read, whatever grants its holder actually has. And it cannot mint another token, this endpoint included.\n\n" + - "Minting requires a COOKIE SESSION. No Bearer credential reaches this endpoint at any scope, wildcard included — not because a wildcard lacks the reach, but because of the lifetimes involved: a native access token lives a day and what it could mint here lives a year, so admitting one would let a short-lived compromise leave behind a credential that outlives revoking it. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute, and at most 10 live tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}` like any other — revoking frees a slot against the ceiling.", + "Minting requires a COOKIE SESSION with a fresh proof: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one), otherwise 401 `auth.reproof.required` with `meta.methods`. No Bearer credential reaches this endpoint at any scope, wildcard included — not because a wildcard lacks the reach, but because of the lifetimes involved: a native access token lives a day and what it could mint here lives a year, so admitting one would let a short-lived compromise leave behind a credential that outlives revoking it. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute, and at most 10 live tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}` like any other — revoking frees a slot against the ceiling.", requestBody: { required: true, content: { @@ -1676,7 +1731,7 @@ export const authPaths: NonNullable = { "Mints a Bearer scoped to exactly `documents:write` and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN, once; it is stored as an HMAC and no path re-reveals it.\n\n" + "**What it can do.** `POST /api/documents/inbound` on its owner's own record, and `GET /api/documents/inbound/source`, which answers whether a source key is already held so an importer can skip a download. Nothing else. It is the door another document system pushes through: a Paperless-ngx workflow posting a newly tagged document, or an import script copying an archive. Uploads through it draw on a bucket of their own, keyed on the token (default 120 an hour, operator-tunable via `DOCUMENT_UPLOAD_LIMIT_PER_HOUR`), and get back a receipt (`id`, `duplicate`, and `deleted` when the source key belongs to a document the owner deleted) rather than the stored row.\n\n" + "**What it cannot do.** List, read, download, preview, edit, delete or bulk-act on a document, reach the document-AI routes, act on a shared record, or mint another token. Every other route names no scope and refuses it 403.\n\n" + - "Minting requires a COOKIE SESSION, for the reason the measurement mint gives: a day-lived native access token must not be able to leave behind a year-lived credential. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute, and at most 10 live document tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}`.", + "Minting requires a COOKIE SESSION with a fresh proof, as for the measurement mint, for the reason it gives: a day-lived native access token must not be able to leave behind a year-lived credential. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute, and at most 10 live document tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}`.", requestBody: { required: true, content: { diff --git a/src/lib/openapi/routes/export.ts b/src/lib/openapi/routes/export.ts index dfd157c28..9838e73fc 100644 --- a/src/lib/openapi/routes/export.ts +++ b/src/lib/openapi/routes/export.ts @@ -198,7 +198,7 @@ export const exportPaths: NonNullable = { tags: ["Export"], summary: "Download the restore-compatible full backup", description: - "The disaster-recovery-shaped JSON dump, in its PORTABLE form. It matches the canonical backup payload byte for byte with the weekly worker's file, so a user can hand this to an operator and the admin restore accepts it without conversion. Two things the shape cannot state: this route ALWAYS produces the portable mode — it calls the builder with no options, so there is no parameter that reaches the disaster-recovery mode, which is the worker and admin path alone — and the file is NOT the standard response envelope but the raw payload, because the restore expects the payload and not `{ data: … }`. Encrypted columns arrive decrypted, so the file is as sensitive as the record it came from; the `manifest` key inside it declares what was deliberately left out. `Content-Disposition: attachment` with `healthlog-backup--.json`, `Cache-Control: no-store`. Shares the `export:` bucket (10 per hour) with every other export route. Audits as `user.export.full-backup` with the row counts. Cookie or Bearer auth; the caller is always resolved as themselves, so this download cannot be delegated to a shared record.", + "The disaster-recovery-shaped JSON dump, in its PORTABLE form. It matches the canonical backup payload byte for byte with the weekly worker's file, so a user can hand this to an operator and the admin restore accepts it without conversion. Two things the shape cannot state: this route ALWAYS produces the portable mode — it calls the builder with no options, so there is no parameter that reaches the disaster-recovery mode, which is the worker and admin path alone — and the file is NOT the standard response envelope but the raw payload, because the restore expects the payload and not `{ data: … }`. Encrypted columns arrive decrypted, so the file is as sensitive as the record it came from; the `manifest` key inside it declares what was deliberately left out. `Content-Disposition: attachment` with `healthlog-backup--.json`, `Cache-Control: no-store`. Shares the `export:` bucket (10 per hour) with every other export route. Audits as `user.export.full-backup` with the row counts. Cookie or Bearer auth; the caller is always resolved as themselves, so this download cannot be delegated to a shared record. 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 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.", responses: { "200": { description: @@ -292,7 +292,7 @@ export const exportPaths: NonNullable = { tags: ["Export"], summary: "Download the full backup as a passphrase-encrypted archive", description: - "v1.23. Same restore-compatible payload as the plaintext full backup, sealed into an `HLX1` archive (Argon2id-derived key + AES-256-GCM) under the supplied `passphrase`. Returns `application/octet-stream` (a `.hlx` file). When the account has a second factor enrolled, the call is step-up gated (`requireFreshMfa`, cookie-only) and returns 401 `auth.stepup.required` without a fresh factor; single-factor accounts use a normal session or Bearer. Shared `export:` rate bucket (10/h). The passphrase is never stored — a forgotten passphrase makes the archive unrecoverable.", + "v1.23. Same restore-compatible payload as the plaintext full backup, sealed into an `HLX1` archive (Argon2id-derived key + AES-256-GCM) under the supplied `passphrase`. Returns `application/octet-stream` (a `.hlx` file). Asks for a fresh proof, like the plaintext full backup. 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. On Bearer, an account without a second factor passes on its token, and one with a second factor presents a second-factor `X-Step-Up` elevation (401 `auth.stepup.required` without it). Shared `export:` rate bucket (10/h). The passphrase is never stored — a forgotten passphrase makes the archive unrecoverable.", requestBody: { required: true, content: { diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts index 0da6adf1f..b4abc529b 100644 --- a/src/lib/openapi/routes/health-record.ts +++ b/src/lib/openapi/routes/health-record.ts @@ -146,7 +146,7 @@ export const healthRecordPaths: NonNullable = { tags: ["Export"], summary: "Create a clinician share link (v1.11.0)", description: - "Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response, together with a passphrase second factor and a `qrUrl` carrying that passphrase in the URL fragment; a leaked URL without the passphrase cannot open the record. The window and the `selection` are frozen write-once — there is no widen path. `selection` omitted means an empty scope, not a default one, and the `INSURANCE` leaf is refused with a 422 because the share view never decrypts the insurance number. An optional `documentIds` array (bounded ≤50) is validated as the caller's own live documents and frozen onto the link; a documents-only share (no report scope, non-empty `documentIds`) is valid. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). Strict: unknown keys 422.", + "Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response, together with a passphrase second factor and a `qrUrl` carrying that passphrase in the URL fragment; a leaked URL without the passphrase cannot open the record. The window and the `selection` are frozen write-once — there is no widen path. `selection` omitted means an empty scope, not a default one, and the `INSURANCE` leaf is refused with a 422 because the share view never decrypts the insurance number. An optional `documentIds` array (bounded ≤50) is validated as the caller's own live documents and frozen onto the link; a documents-only share (no report scope, non-empty `documentIds`) is valid. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). Strict: 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. A Bearer caller is not asked for more than its token yet.", requestBody: { required: true, content: { diff --git a/src/lib/openapi/routes/mcp.ts b/src/lib/openapi/routes/mcp.ts index c1f8b22e7..ca585bd6e 100644 --- a/src/lib/openapi/routes/mcp.ts +++ b/src/lib/openapi/routes/mcp.ts @@ -196,7 +196,7 @@ export const mcpPaths: NonNullable = { tags: ["MCP"], summary: "Mint an MCP connector token", description: - "Mints a `health:read` (optionally read+write) Bearer for the manual / stdio path and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN — this is the only place it ever exists, and it cannot be retrieved again. Body capped at 16 KiB. Auth via cookie or Bearer.", + "Mints a `health:read` (optionally read+write) Bearer for the manual / stdio path and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN — this is the only place it ever exists, and it cannot be retrieved again. Body capped at 16 KiB. Auth via cookie or Bearer. 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 is not asked for more than its token yet.", requestBody: { required: true, content: { "application/json": { schema: createMcpTokenRequest } }, diff --git a/tests/integration/admin-data-wipe.test.ts b/tests/integration/admin-data-wipe.test.ts index 1aaeda45f..0855d8bb7 100644 --- a/tests/integration/admin-data-wipe.test.ts +++ b/tests/integration/admin-data-wipe.test.ts @@ -159,10 +159,13 @@ describe("DELETE /api/admin/data leaves nothing it promised to delete", () => { ).toBeGreaterThan(100); // Created after the generic seed so the cookie carries this session's id. + // The seed gives the admin a second factor, and the wipe asks for a fresh + // proof, so the session is one that has just completed it. const session = await prisma.session.create({ data: { userId: admin.id, expiresAt: new Date(Date.now() + 60_000), + mfaVerifiedAt: new Date(), }, }); cookieJar.set("healthlog_session", session.id); diff --git a/tests/integration/bearer-scope-enforcement.test.ts b/tests/integration/bearer-scope-enforcement.test.ts index d8e082401..5d968d8e5 100644 --- a/tests/integration/bearer-scope-enforcement.test.ts +++ b/tests/integration/bearer-scope-enforcement.test.ts @@ -248,14 +248,36 @@ describe("B2/B3 — the medication-ingest surface is unchanged", () => { }); describe("B4 — the native client is not broken", () => { - it("admits a ['*'] token on GET /api/export/full-backup", async () => { + it("admits a ['*'] token on POST /api/export/encrypted", async () => { // Login, passkey login-verify and refresh rotation all mint `["*"]`, so - // this is exactly the credential the iOS app holds. If this case ever - // goes red, the native client is down. + // this is exactly the credential the iOS app holds, on the whole-record + // export the app calls. If this case ever goes red, the app's backup is + // down. + await armToken(["*"], "wildcard"); + const { POST } = await import("@/app/api/export/encrypted/route"); + const res = await POST( + new NextRequest("https://health.example/api/export/encrypted", { + method: "POST", + headers: { + authorization: headerJar.get("authorization")!, + "content-type": "application/json", + }, + body: JSON.stringify({ passphrase: "a long enough passphrase" }), + }), + ); + expect(res.status).toBe(200); + await res.arrayBuffer(); + }); + + it("passes a ['*'] token through the scope check on GET /api/export/full-backup, to the elevation it needs", async () => { + // The plaintext full backup asks a Bearer caller for a step-up + // elevation. No shipped client calls it on Bearer. What this pins is that + // the refusal is the proof gate's 401, not the scope layer's 403. await armToken(["*"], "wildcard"); const { GET } = await import("@/app/api/export/full-backup/route"); const res = await GET(req("/api/export/full-backup")); - expect(res.status).toBe(200); + expect(res.status).toBe(401); + expect((await res.json()).meta.errorCode).toBe("auth.stepup.required"); }); it("admits a ['*'] token on a batch ingest route", async () => { diff --git a/tests/integration/export-per-type.test.ts b/tests/integration/export-per-type.test.ts index 567a11d9f..6b7b2ed08 100644 --- a/tests/integration/export-per-type.test.ts +++ b/tests/integration/export-per-type.test.ts @@ -136,6 +136,12 @@ describe("GET /api/export/measurements", () => { const audits = await prisma.auditLog.findMany({ where: { userId: me.id, action: "user.export.measurements" }, }); + console.log( + "DBG", + JSON.stringify( + await prisma.auditLog.findMany({ where: { userId: me.id } }), + ), + ); expect(audits.length).toBe(1); }); }); @@ -258,6 +264,12 @@ describe("GET /api/export/mood", () => { const audits = await prisma.auditLog.findMany({ where: { userId: me.id, action: "user.export.mood" }, }); + console.log( + "DBG", + JSON.stringify( + await prisma.auditLog.findMany({ where: { userId: me.id } }), + ), + ); expect(audits.length).toBe(1); }); }); @@ -317,9 +329,13 @@ describe("GET /api/export/full-backup", () => { expect(parsed.measurements.length).toBe(1); expect(parsed.moodEntries.length).toBe(1); - const audits = await prisma.auditLog.findMany({ - where: { userId: me.id, action: "user.export.full-backup" }, + // The audit row is written once the last byte has gone out, which is + // after the body resolves here; wait for it rather than race it. + await vi.waitFor(async () => { + const audits = await prisma.auditLog.findMany({ + where: { userId: me.id, action: "user.export.full-backup" }, + }); + expect(audits.length).toBe(1); }); - expect(audits.length).toBe(1); }); }); diff --git a/tests/integration/recent-proof.test.ts b/tests/integration/recent-proof.test.ts new file mode 100644 index 000000000..1a1325823 --- /dev/null +++ b/tests/integration/recent-proof.test.ts @@ -0,0 +1,212 @@ +/** + * The fresh-proof gate in front of whole-record exports, share links, token + * minting and the admin data actions, against real Postgres. + * + * A live browser session is not enough for these: it needs a sign-in or a + * re-proof inside five minutes (a second factor, on an account that has one). + * `POST /api/auth/reproof` is how a session re-proves in place. + */ +import { NextRequest } from "next/server"; +import * as OTPAuth from "otpauth"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-recent-proof-32-bytes-minimum-1234567890ab"; + +const { hashPassword } = await import("@/lib/auth/password"); + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const USER_ID = "user-recent-proof"; +const PASSWORD = "Correct horse battery staple!42"; +const HOUR = 60 * 60 * 1000; + +type RouteFn = (request: NextRequest) => Promise; + +function request(method: string, path: string, body?: unknown): NextRequest { + return new NextRequest(`http://localhost${path}`, { + method, + headers: body === undefined ? {} : { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +async function fullBackup() { + const { GET } = await import("@/app/api/export/full-backup/route"); + return (GET as unknown as RouteFn)(request("GET", "/api/export/full-backup")); +} + +async function reproof(body: unknown) { + const { POST } = await import("@/app/api/auth/reproof/route"); + return (POST as unknown as RouteFn)( + request("POST", "/api/auth/reproof", body), + ); +} + +async function startSession(createdAgoMs: number): Promise { + const now = Date.now(); + const session = await getPrismaClient().session.create({ + data: { + userId: USER_ID, + createdAt: new Date(now - createdAgoMs), + expiresAt: new Date(now + HOUR), + }, + }); + cookieJar.set("healthlog_session", session.id); + return session.id; +} + +async function body(res: Response) { + return (await res.json()) as { + meta?: { errorCode?: string; methods?: string[] }; + }; +} + +async function enrollTotp(): Promise { + const { generateTotpSecret } = await import("@/lib/auth/mfa/totp"); + const { encrypt } = await import("@/lib/crypto"); + const secret = generateTotpSecret(); + await getPrismaClient().user.update({ + where: { id: USER_ID }, + data: { + totpSecretEncrypted: encrypt(secret), + totpConfirmedAt: new Date(), + totpLastStep: null, + }, + }); + return secret; +} + +function codeFor(secret: string): string { + return new OTPAuth.TOTP({ + issuer: "HealthLog", + label: "HealthLog", + algorithm: "SHA1", + digits: 6, + period: 30, + secret: OTPAuth.Secret.fromBase32(secret), + }).generate({ timestamp: Date.now() }); +} + +beforeEach(async () => { + vi.clearAllMocks(); + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: "recent-proof-user", + email: "recent-proof@example.test", + role: "USER", + passwordHash: await hashPassword(PASSWORD), + }, + }); +}); + +describe("account without a second factor", () => { + it("exports right after signing in", async () => { + await startSession(30_000); + const res = await fullBackup(); + expect(res.status).toBe(200); + await res.text(); + }); + + it("refuses an old session and names the password as the way through", async () => { + await startSession(HOUR); + const res = await fullBackup(); + expect(res.status).toBe(401); + const b = await body(res); + expect(b.meta?.errorCode).toBe("auth.reproof.required"); + expect(b.meta?.methods).toEqual(["password"]); + }); + + it("exports after a password re-proof, which stamps only reproofAt", async () => { + const sessionId = await startSession(HOUR); + const proved = await reproof({ method: "password", password: PASSWORD }); + expect(proved.status).toBe(200); + const row = await getPrismaClient().session.findUniqueOrThrow({ + where: { id: sessionId }, + }); + expect(row.reproofAt).not.toBeNull(); + expect(row.mfaVerifiedAt).toBeNull(); + + const res = await fullBackup(); + expect(res.status).toBe(200); + await res.text(); + }); + + it("refuses and audits a wrong password without stamping", async () => { + const sessionId = await startSession(HOUR); + const res = await reproof({ method: "password", password: "nope" }); + expect(res.status).toBe(401); + expect((await body(res)).meta?.errorCode).toBe("auth.reproof.failed"); + const row = await getPrismaClient().session.findUniqueOrThrow({ + where: { id: sessionId }, + }); + expect(row.reproofAt).toBeNull(); + expect( + await getPrismaClient().auditLog.count({ + where: { userId: USER_ID, action: "auth.mfa.failed" }, + }), + ).toBe(1); + }); +}); + +describe("account with a second factor", () => { + it("refuses the password as a proof before spending a guess", async () => { + await enrollTotp(); + await startSession(HOUR); + const res = await reproof({ method: "password", password: PASSWORD }); + expect(res.status).toBe(422); + const b = await body(res); + expect(b.meta?.errorCode).toBe("auth.reproof.too_weak"); + expect(b.meta?.methods).toEqual(["totp"]); + expect( + await getPrismaClient().rateLimit.count({ + where: { key: `auth:step-up:${USER_ID}` }, + }), + ).toBe(0); + }); + + it("a fresh sign-in alone is not enough; a TOTP re-proof is", async () => { + const secret = await enrollTotp(); + const sessionId = await startSession(30_000); + const refused = await fullBackup(); + expect(refused.status).toBe(401); + expect((await body(refused)).meta?.methods).toEqual(["totp"]); + + const proved = await reproof({ method: "totp", code: codeFor(secret) }); + expect(proved.status).toBe(200); + const row = await getPrismaClient().session.findUniqueOrThrow({ + where: { id: sessionId }, + }); + expect(row.mfaVerifiedAt).not.toBeNull(); + + const res = await fullBackup(); + expect(res.status).toBe(200); + await res.text(); + }); +});