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 1/9] 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}