diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 9be8d8729..3187532b4 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -917,7 +917,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:
@@ -1587,7 +1591,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.
@@ -1788,12 +1796,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:
@@ -2063,9 +2074,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
@@ -2088,6 +2102,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:
@@ -2523,8 +2595,21 @@ paths:
tags:
- Auth
summary: Sign out everywhere else
- description: v1.23 — revokes every OTHER web session plus all native refresh tokens, keeping the caller's current
- session. API tokens are not touched (manage those under /settings/api-tokens).
+ description: "Ends every credential except the caller's own: every OTHER web session, every other device login (refresh
+ token and its paired access token), every trusted device and step-up elevation, every connected AI assistant
+ (the OAuth connection, so it cannot mint a new access token), every programmatic token (connector, measurement
+ and document tokens), and every clinician share link. Share links are revoked by default because a link made by
+ whoever held a lost session or device would otherwise keep opening the record; send `keepShareLinks=1` to keep
+ them. A Bearer caller keeps its own device login."
+ parameters:
+ - in: query
+ name: keepShareLinks
+ schema:
+ description: "`1` keeps the clinician share links. Anything else, or nothing, revokes them."
+ type: string
+ enum:
+ - "1"
+ description: "`1` keeps the clinician share links. Anything else, or nothing, revokes them."
responses:
"200":
description: Other sessions revoked.
@@ -2898,12 +2983,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:
@@ -2990,10 +3077,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:
@@ -20205,9 +20292,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:
@@ -20892,7 +20982,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
@@ -20956,7 +21048,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
@@ -31293,10 +31388,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,
@@ -38292,6 +38387,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:
@@ -38798,8 +38954,27 @@ components:
minimum: -9007199254740991
maximum: 9007199254740991
description: Number of OTHER sessions removed (the current one is kept).
+ accessTokensRevoked:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ description: "Tokens revoked: the access tokens of other device logins plus every programmatic token (connector,
+ measurement, document), never the caller's own."
+ connectorsRevoked:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ description: Connected AI-assistant (MCP OAuth) connections revoked.
+ shareLinksRevoked:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ description: Clinician share links revoked; 0 when `keepShareLinks=1` was sent.
required:
- sessionsRevoked
+ - accessTokensRevoked
+ - connectorsRevoked
+ - shareLinksRevoked
additionalProperties: false
SessionRevokeEnvelope:
type: object
diff --git a/docs/self-hosting/reverse-proxy.md b/docs/self-hosting/reverse-proxy.md
index 7f18095ec..6ef02203b 100644
--- a/docs/self-hosting/reverse-proxy.md
+++ b/docs/self-hosting/reverse-proxy.md
@@ -40,17 +40,23 @@ chain from the **right**, counting back `TRUST_PROXY_HOPS` entries.
This guards against a client rotating `X-Forwarded-For` per request
to defeat IP-based rate limits.
-| Topology | `TRUST_PROXY_HOPS` |
-| --------------------------------------------------------- | -------------------------- |
-| App is internet-facing with no proxy | `0` (XFF ignored entirely) |
-| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) |
-| Cloudflare → your proxy → app | `2` |
-| Cloudflare → Coolify-Tunnel → Coolify → app | `3` |
+| Topology | `TRUST_PROXY_HOPS` |
+| --------------------------------------------------------- | ------------------------------------- |
+| App is internet-facing with no proxy | `0` (no forwarding header is trusted) |
+| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) |
+| Cloudflare → your proxy → app | `2` |
+| Cloudflare → Coolify-Tunnel → Coolify → app | `3` |
A misconfigured count logs a one-shot warning to stderr and collapses
every anonymous caller into one shared rate-limit bucket. Match the
-value to the actual hop count or set it to `0` and let `x-real-ip`
-drive the IP resolution.
+value to the actual hop count.
+
+`X-Real-IP` gets the same trust as `X-Forwarded-For`. It is read only
+when `TRUST_PROXY_HOPS` is `1` or more and the request carries no
+`X-Forwarded-For`, which covers a proxy that sets only `X-Real-IP`. With
+`0` neither header is believed, because without a proxy in front both
+come from the caller. If your proxy sets only `X-Real-IP` and you had
+set `0` for it, set `1` instead.
## Caddy
diff --git a/messages/de.json b/messages/de.json
index 7d6bbe83c..d8e16465e 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."
}
@@ -6556,7 +6555,8 @@
"revokeSessionConfirmTitle": "Dieses Gerät abmelden?",
"revokeSessionConfirmBody": "{device} muss sich neu anmelden. An den dort erfassten Daten ändert sich nichts.",
"signOutEverywhereConfirmTitle": "Alle anderen Geräte abmelden?",
- "signOutEverywhereConfirmBody": "Jedes Gerät außer diesem muss sich neu anmelden. Dieses Gerät bleibt angemeldet. An deinen erfassten Daten ändert sich nichts."
+ "signOutEverywhereConfirmBody": "Alle anderen Geräte, jeder verbundene KI-Assistent und jedes API-Token müssen sich neu anmelden oder neu eingerichtet werden. Dieses Gerät bleibt angemeldet. Deine Einträge bleiben unberührt.",
+ "signOutEverywhereShareLinks": "Auch die Freigabelinks für Ärztinnen und Ärzte beenden"
},
"privacy": {
"encryption": {
@@ -6739,7 +6739,12 @@
"moduleOffExplainer": "Dokumente sind ausgeschaltet, die Verbindung unten wird daher nicht genutzt. Du kannst sie weiterhin entfernen.",
"tokenNewAddress": "Für eine neue Adresse wird das API-Token erneut gebraucht."
},
- "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 fe173e647..7e52ec2bb 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."
}
@@ -6556,7 +6555,8 @@
"revokeSessionConfirmTitle": "Sign this device out?",
"revokeSessionConfirmBody": "{device} will need to sign in again. Nothing recorded on it is affected.",
"signOutEverywhereConfirmTitle": "Sign out every other device?",
- "signOutEverywhereConfirmBody": "Every device except this one will need to sign in again. This device stays signed in. Nothing you have recorded is affected."
+ "signOutEverywhereConfirmBody": "Every other device, every connected AI assistant and every API token will need to sign in or be set up again. This device stays signed in. Nothing you have recorded is affected.",
+ "signOutEverywhereShareLinks": "Also end the doctor share links"
},
"privacy": {
"encryption": {
@@ -6739,7 +6739,12 @@
"moduleOffExplainer": "Documents are switched off, so the connection below is not used. You can still remove it.",
"tokenNewAddress": "A new address needs the API token again."
},
- "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 eeb0f7a65..912be0c34 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."
}
@@ -6556,7 +6555,8 @@
"revokeSessionConfirmTitle": "¿Cerrar la sesión de este dispositivo?",
"revokeSessionConfirmBody": "{device} tendrá que iniciar sesión de nuevo. Lo registrado en él no se ve afectado.",
"signOutEverywhereConfirmTitle": "¿Cerrar la sesión en los demás dispositivos?",
- "signOutEverywhereConfirmBody": "Todos los dispositivos salvo este tendrán que iniciar sesión de nuevo. Este seguirá con la sesión abierta. Nada de lo que has registrado se ve afectado."
+ "signOutEverywhereConfirmBody": "Todos los demás dispositivos, cada asistente de IA conectado y cada token de API tendrán que volver a iniciar sesión o configurarse de nuevo. Este dispositivo sigue con la sesión iniciada. Nada de lo que has registrado se ve afectado.",
+ "signOutEverywhereShareLinks": "Terminar también los enlaces compartidos con profesionales médicos"
},
"privacy": {
"encryption": {
@@ -6739,7 +6739,12 @@
"moduleOffExplainer": "Los documentos están desactivados, así que la conexión de abajo no se usa. Aún puedes eliminarla.",
"tokenNewAddress": "Una dirección nueva necesita de nuevo el token de la API."
},
- "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 cf47be25a..e7323049e 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."
}
@@ -6556,7 +6555,8 @@
"revokeSessionConfirmTitle": "Déconnecter cet appareil ?",
"revokeSessionConfirmBody": "{device} devra se reconnecter. Ce qui y est enregistré n'est pas affecté.",
"signOutEverywhereConfirmTitle": "Déconnecter tous les autres appareils ?",
- "signOutEverywhereConfirmBody": "Chaque appareil sauf celui-ci devra se reconnecter. Celui-ci reste connecté. Rien de ce que tu as enregistré n'est affecté."
+ "signOutEverywhereConfirmBody": "Tous les autres appareils, chaque assistant IA connecté et chaque jeton d'API devront se reconnecter ou être reconfigurés. Cet appareil reste connecté. Rien de ce que vous avez enregistré n'est touché.",
+ "signOutEverywhereShareLinks": "Mettre fin aussi aux liens de partage avec les médecins"
},
"privacy": {
"encryption": {
@@ -6739,7 +6739,12 @@
"moduleOffExplainer": "Les documents sont désactivés ; la connexion ci-dessous n'est donc pas utilisée. Vous pouvez toujours la supprimer.",
"tokenNewAddress": "Une nouvelle adresse demande de saisir à nouveau le jeton d'API."
},
- "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 299c55421..c2345d3fa 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."
}
@@ -6556,7 +6555,8 @@
"revokeSessionConfirmTitle": "Disconnettere questo dispositivo?",
"revokeSessionConfirmBody": "{device} dovrà accedere di nuovo. Quanto registrato su di esso non viene toccato.",
"signOutEverywhereConfirmTitle": "Disconnettere tutti gli altri dispositivi?",
- "signOutEverywhereConfirmBody": "Ogni dispositivo tranne questo dovrà accedere di nuovo. Questo resta connesso. Nulla di ciò che hai registrato viene toccato."
+ "signOutEverywhereConfirmBody": "Tutti gli altri dispositivi, ogni assistente IA collegato e ogni token API dovranno accedere di nuovo o essere configurati di nuovo. Questo dispositivo resta connesso. Nulla di ciò che hai registrato viene toccato.",
+ "signOutEverywhereShareLinks": "Termina anche i link di condivisione con i medici"
},
"privacy": {
"encryption": {
@@ -6739,7 +6739,12 @@
"moduleOffExplainer": "I documenti sono disattivati, quindi il collegamento qui sotto non viene usato. Puoi comunque rimuoverlo.",
"tokenNewAddress": "Un nuovo indirizzo richiede di nuovo il token API."
},
- "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 075892e25..74e4d5285 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 파일 하나에."
}
@@ -6556,7 +6555,8 @@
"revokeSessionConfirmTitle": "이 기기를 로그아웃할까요?",
"revokeSessionConfirmBody": "{device} — 다시 로그인해야 해요. 기기에 기록된 내용은 영향받지 않아요.",
"signOutEverywhereConfirmTitle": "다른 기기를 모두 로그아웃할까요?",
- "signOutEverywhereConfirmBody": "이 기기를 뺀 모든 기기가 다시 로그인해야 해요. 이 기기는 로그인 상태로 남아요. 기록한 내용은 영향받지 않아요."
+ "signOutEverywhereConfirmBody": "다른 모든 기기, 연결된 모든 AI 어시스턴트와 모든 API 토큰은 다시 로그인하거나 다시 설정해야 합니다. 이 기기는 로그인 상태로 유지됩니다. 기록한 내용에는 영향이 없습니다.",
+ "signOutEverywhereShareLinks": "의료진 공유 링크도 종료"
},
"privacy": {
"encryption": {
@@ -6739,7 +6739,12 @@
"moduleOffExplainer": "문서 기능이 꺼져 있어 아래 연결은 사용되지 않습니다. 연결은 여전히 삭제할 수 있습니다.",
"tokenNewAddress": "새 주소에는 API 토큰을 다시 입력해야 합니다."
},
- "emailChangePassword": "현재 비밀번호 (이메일 주소를 변경하려면 필요)"
+ "emailChangePassword": "현재 비밀번호 (이메일 주소를 변경하려면 필요)",
+ "reproof": {
+ "description": "건강 데이터를 공유하거나 내보내는 작업입니다. 계속하려면 본인임을 확인하세요.",
+ "signInAgain": "다시 로그인한 후 한 번 더 시도하세요. 이 계정에는 여기서 확인할 비밀번호나 패스키가 없습니다.",
+ "failed": "확인하지 못했습니다. 입력한 내용을 확인하고 다시 시도하세요."
+ }
},
"admin": {
"title": "관리자",
diff --git a/messages/pl.json b/messages/pl.json
index 858796955..95220aed4 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."
}
@@ -6556,7 +6555,8 @@
"revokeSessionConfirmTitle": "Wylogować to urządzenie?",
"revokeSessionConfirmBody": "{device} będzie musiało zalogować się ponownie. Zapisane na nim dane pozostają bez zmian.",
"signOutEverywhereConfirmTitle": "Wylogować wszystkie pozostałe urządzenia?",
- "signOutEverywhereConfirmBody": "Każde urządzenie poza tym będzie musiało zalogować się ponownie. To urządzenie pozostaje zalogowane. Zapisane dane pozostają bez zmian."
+ "signOutEverywhereConfirmBody": "Wszystkie inne urządzenia, każdy połączony asystent AI i każdy token API będą musiały zalogować się ponownie lub zostać skonfigurowane od nowa. To urządzenie pozostaje zalogowane. Zapisane dane pozostają bez zmian.",
+ "signOutEverywhereShareLinks": "Zakończ także linki udostępniania dla lekarzy"
},
"privacy": {
"encryption": {
@@ -6739,7 +6739,12 @@
"moduleOffExplainer": "Dokumenty są wyłączone, więc poniższe połączenie nie jest używane. Nadal możesz je usunąć.",
"tokenNewAddress": "Nowy adres wymaga ponownego podania tokenu API."
},
- "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 6c6c06bca..9ea0208a2 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1171,6 +1171,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__/ai-consent-enforcement-guard.test.ts b/src/__tests__/ai-consent-enforcement-guard.test.ts
index adfa10b47..16e41710c 100644
--- a/src/__tests__/ai-consent-enforcement-guard.test.ts
+++ b/src/__tests__/ai-consent-enforcement-guard.test.ts
@@ -183,18 +183,22 @@ describe("every provider-resolving module carries a consent helper", () => {
});
describe("the consent guard itself stays the single source of the policy", () => {
- it("keeps the server-managed provider set defined in exactly one place", () => {
- // Both operator-credential tags must be classified in consent-guard.ts and
- // nowhere else — a second copy of this list is how the policy drifts.
- const guard = read("lib/ai/consent-guard.ts");
- expect(guard).toContain('"admin-openai"');
- expect(guard).toContain('"admin-codex"');
+ it("keeps the operator-held provider set defined in exactly one place", () => {
+ // The two operator-credential tags are listed in provider-egress.ts and
+ // nowhere else, and the consent guard reads them from there. A second copy
+ // is how the policy drifts. This guard used to match one variable NAME, so
+ // a copy under another name in the capability resolver went unseen for as
+ // long as it existed; it now matches the list itself, in either order.
+ const pair =
+ /\[\s*"admin-(?:openai|codex)"\s*,\s*"admin-(?:openai|codex)"\s*,?\s*\]/;
+ const holders = sourceFiles().filter((rel) => pair.test(read(rel)));
+ expect(holders).toEqual(["lib/ai/provider-egress.ts"]);
- const copies = sourceFiles().filter(
- (rel) =>
- rel !== "lib/ai/consent-guard.ts" &&
- /SERVER_MANAGED_PROVIDER_TYPES/.test(read(rel)),
+ expect(read("lib/ai/consent-guard.ts")).toMatch(
+ /from "@\/lib\/ai\/provider-egress"/,
+ );
+ expect(read("lib/ai/capabilities/resolve.ts")).toMatch(
+ /from "@\/lib\/ai\/provider-egress"/,
);
- expect(copies).toEqual([]);
});
});
diff --git a/src/__tests__/api/auth/login/mfa-branch.test.ts b/src/__tests__/api/auth/login/mfa-branch.test.ts
index 79de4e666..3210a5c44 100644
--- a/src/__tests__/api/auth/login/mfa-branch.test.ts
+++ b/src/__tests__/api/auth/login/mfa-branch.test.ts
@@ -29,6 +29,8 @@ vi.mock("@/lib/rate-limit", () => ({
resetAt: Date.now() + 1e6,
ip: "203.0.113.7",
}),
+ checkRateLimit: vi.fn(),
+ refundRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/auth/login-response", () => ({
@@ -61,6 +63,7 @@ import { POST } from "@/app/api/auth/login/route";
import { prisma } from "@/lib/db";
import { finishLogin } from "@/lib/auth/login-response";
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
+import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
function loginRequest() {
return new Request("http://localhost/api/auth/login", {
@@ -85,6 +88,12 @@ beforeEach(() => {
vi.clearAllMocks();
// Default: no registered security key. Individual tests override as needed.
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
+ vi.mocked(checkRateLimit).mockResolvedValue({
+ allowed: true,
+ limit: 10,
+ remaining: 9,
+ resetAt: Date.now() + 1e6,
+ });
});
describe("login MFA branch", () => {
@@ -155,3 +164,52 @@ describe("login MFA branch", () => {
expect(finishLogin).not.toHaveBeenCalled();
});
});
+
+describe("per-account login ceiling", () => {
+ it("refuses a throttled account before any password is verified", async () => {
+ const { verifyPasswordOrDummy } = await import("@/lib/auth/password");
+ vi.mocked(prisma.user.findFirst).mockResolvedValue({
+ ...BASE_USER,
+ totpConfirmedAt: null,
+ } as never);
+ vi.mocked(checkRateLimit).mockResolvedValueOnce({
+ allowed: false,
+ limit: 10,
+ remaining: 0,
+ resetAt: Date.now() + 1e6,
+ });
+
+ const res = await POST(loginRequest());
+ expect(res.status).toBe(429);
+ expect(verifyPasswordOrDummy).not.toHaveBeenCalled();
+ expect(finishLogin).not.toHaveBeenCalled();
+ expect(vi.mocked(checkRateLimit).mock.calls[0][0]).toBe(
+ "auth:login:account:u:user-1",
+ );
+ });
+
+ it("keys an unknown identifier on its hash, so a lock says nothing about existence", async () => {
+ vi.mocked(prisma.user.findFirst).mockResolvedValue(null as never);
+ const res = await POST(loginRequest());
+ expect(res.status).toBe(401);
+ expect(vi.mocked(checkRateLimit).mock.calls[0][0]).toMatch(
+ /^auth:login:account:i:/,
+ );
+ });
+
+ it("refunds a successful sign-in and keeps a failed one counted", async () => {
+ const { verifyPasswordOrDummy } = await import("@/lib/auth/password");
+ vi.mocked(prisma.user.findFirst).mockResolvedValue({
+ ...BASE_USER,
+ totpConfirmedAt: null,
+ } as never);
+
+ await POST(loginRequest());
+ expect(refundRateLimit).toHaveBeenCalledWith("auth:login:account:u:user-1");
+
+ vi.mocked(refundRateLimit).mockClear();
+ vi.mocked(verifyPasswordOrDummy).mockResolvedValueOnce(false);
+ await POST(loginRequest());
+ expect(refundRateLimit).not.toHaveBeenCalled();
+ });
+});
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/provider-health/route.ts b/src/app/api/admin/provider-health/route.ts
index 78e90ca01..74579d8c3 100644
--- a/src/app/api/admin/provider-health/route.ts
+++ b/src/app/api/admin/provider-health/route.ts
@@ -3,6 +3,7 @@ import { buildDateKey } from "@/lib/ai/coach/budget";
import { prisma } from "@/lib/db";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
+import { OPERATOR_HELD_PROVIDER_TYPES } from "@/lib/ai/provider-egress";
export const dynamic = "force-dynamic";
@@ -43,8 +44,11 @@ interface ProviderHealthSummary {
lastFailureStatus: number | null;
}
-/** The two operator-managed tags sort first — they affect every user on the chain. */
-const CENTRAL_TYPES = ["admin-openai", "admin-codex"];
+/**
+ * The operator-managed tags sort first — they affect every user on the chain.
+ * Read from the one definition, in its order (the OpenAI key, then Codex).
+ */
+const CENTRAL_TYPES = [...OPERATOR_HELD_PROVIDER_TYPES];
export const GET = apiHandler(async () => {
await requireAdmin();
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 11a6a40c7..ae913dddc 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/login/__tests__/constant-work.test.ts b/src/app/api/auth/login/__tests__/constant-work.test.ts
index 87be3506b..977ab9e51 100644
--- a/src/app/api/auth/login/__tests__/constant-work.test.ts
+++ b/src/app/api/auth/login/__tests__/constant-work.test.ts
@@ -44,6 +44,8 @@ vi.mock("@/lib/rate-limit", () => ({
ip: "1.2.3.4",
}),
rateLimitHeaders: vi.fn(() => ({})),
+ checkRateLimit: vi.fn(async () => ({ allowed: true, remaining: 9 })),
+ refundRateLimit: vi.fn(async () => undefined),
}));
vi.mock("@/lib/db-compat", () => ({
diff --git a/src/app/api/auth/login/__tests__/native-token.test.ts b/src/app/api/auth/login/__tests__/native-token.test.ts
index 7089dbff9..73e5d82d9 100644
--- a/src/app/api/auth/login/__tests__/native-token.test.ts
+++ b/src/app/api/auth/login/__tests__/native-token.test.ts
@@ -48,6 +48,7 @@ vi.mock("@/lib/rate-limit", () => ({
ip: "203.0.113.1",
}),
rateLimitHeaders: vi.fn(() => ({})),
+ refundRateLimit: vi.fn(async () => undefined),
}));
vi.mock("@/lib/db-compat", () => ({
diff --git a/src/app/api/auth/login/__tests__/trusted-device.test.ts b/src/app/api/auth/login/__tests__/trusted-device.test.ts
index f8eab01a4..c6405653a 100644
--- a/src/app/api/auth/login/__tests__/trusted-device.test.ts
+++ b/src/app/api/auth/login/__tests__/trusted-device.test.ts
@@ -39,6 +39,7 @@ vi.mock("@/lib/rate-limit", () => ({
.fn()
.mockResolvedValue({ allowed: true, ip: "1.2.3.4" }),
rateLimitHeaders: vi.fn(() => ({})),
+ refundRateLimit: vi.fn(async () => undefined),
}));
vi.mock("@/lib/db-compat", () => ({
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts
index 63e285c6a..2c07466cb 100644
--- a/src/app/api/auth/login/route.ts
+++ b/src/app/api/auth/login/route.ts
@@ -9,7 +9,12 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
-import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
+import {
+ checkAuthSurfaceRateLimit,
+ checkRateLimit,
+ rateLimitHeaders,
+ refundRateLimit,
+} from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
@@ -21,6 +26,15 @@ import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { isOidcOnly } from "@/lib/auth/oidc";
import { readPendingLink } from "@/lib/auth/oidc-pending-link";
+/**
+ * Failed password sign-ins one account takes per window, from any number of
+ * addresses. Twice the per-IP ceiling, so the owner mistyping from one network
+ * meets that one first, and small enough that guessing is hopeless.
+ */
+const LOGIN_ACCOUNT_BUCKET = "auth:login:account";
+const LOGIN_ACCOUNT_LIMIT = 10;
+const LOGIN_ACCOUNT_WINDOW_MS = 15 * 60 * 1000;
+
export const POST = apiHandler(async (request: NextRequest) => {
// OIDC_ONLY means password login must be a dead end, not just a hidden
// button — otherwise a pre-existing password (or a leaked/reused one)
@@ -91,6 +105,42 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
+ // A per-account ceiling beside the per-IP one. The IP bucket alone is only
+ // as good as the address it is keyed on, and a guesser with many addresses
+ // (or a proxy that forwards whatever address the caller claims) could try
+ // one account's password without end. This bucket follows the account
+ // wherever the guesses come from.
+ //
+ // Keyed on the account when the identifier resolves and on the identifier's
+ // hash when it does not, with the same ceiling, so a locked answer says
+ // nothing about whether the account exists. Charged before the Argon2id
+ // verification (a locked account costs no hashing) and refunded on a
+ // successful sign-in, so it counts failures: the owner signing in does not
+ // use it up. While it is spent, a passkey or single sign-on still works.
+ const accountBucket = user
+ ? `${LOGIN_ACCOUNT_BUCKET}:u:${user.id}`
+ : `${LOGIN_ACCOUNT_BUCKET}:i:${hashToken(identifier.toLowerCase())}`;
+ const accountRl = await checkRateLimit(
+ accountBucket,
+ LOGIN_ACCOUNT_LIMIT,
+ LOGIN_ACCOUNT_WINDOW_MS,
+ );
+ if (!accountRl.allowed) {
+ await auditLog("auth.login.account_throttled", {
+ userId: user?.id,
+ ipAddress: ip,
+ details: { identifierHash },
+ });
+ annotate({ action: { name: "auth.login.account_throttled" } });
+ return NextResponse.json(
+ {
+ data: null,
+ error: "Too many login attempts. Please try again later.",
+ },
+ { status: 429, headers: rateLimitHeaders(accountRl) },
+ );
+ }
+
// One verification for every outcome. An unknown identifier and an
// account carrying no password hash (a passkey-only account) used to
// return here before any hashing, so the three answers cost one indexed
@@ -132,6 +182,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
+ // The password was right: this attempt was not a failed guess.
+ await refundRateLimit(accountBucket);
+
const ua = request.headers.get("user-agent");
await auditLog("auth.login.password", {
diff --git a/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts b/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts
index 6639267e6..a819d6a08 100644
--- a/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts
+++ b/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts
@@ -76,10 +76,14 @@ describe("POST /api/auth/me/mfa/disable", () => {
it("revokes other sessions + trusted devices (via destroyOtherSessions), keeping the current one", async () => {
const res = await POST(req());
expect(res.status).toBe(200);
- expect(destroyOtherSessions).toHaveBeenCalledWith("u1", {
- kind: "session",
- sessionId: "sess-current",
- });
+ expect(destroyOtherSessions).toHaveBeenCalledWith(
+ "u1",
+ {
+ kind: "session",
+ sessionId: "sess-current",
+ },
+ { reach: "sign-ins" },
+ );
});
it("spares the CALLER's own device login on the Bearer path", async () => {
@@ -99,10 +103,14 @@ describe("POST /api/auth/me/mfa/disable", () => {
const res = await POST(req());
expect(res.status).toBe(200);
- expect(destroyOtherSessions).toHaveBeenCalledWith("u1", {
- kind: "accessToken",
- accessTokenHash: "hash-of-caller-access-token",
- });
+ expect(destroyOtherSessions).toHaveBeenCalledWith(
+ "u1",
+ {
+ kind: "accessToken",
+ accessTokenHash: "hash-of-caller-access-token",
+ },
+ { reach: "sign-ins" },
+ );
});
it("spends the elevation before tearing the factor down", async () => {
diff --git a/src/app/api/auth/me/mfa/disable/route.ts b/src/app/api/auth/me/mfa/disable/route.ts
index d1ec12e0f..1493d61e4 100644
--- a/src/app/api/auth/me/mfa/disable/route.ts
+++ b/src/app/api/auth/me/mfa/disable/route.ts
@@ -118,6 +118,7 @@ export const POST = apiHandler(async (req: Request) => {
auth.transport === "cookie"
? { kind: "session", sessionId: auth.session.id }
: { kind: "accessToken", accessTokenHash: auth.accessTokenHash },
+ { reach: "sign-ins" },
);
await auditLog("auth.mfa.disabled", {
diff --git a/src/app/api/auth/me/mfa/totp/confirm/route.ts b/src/app/api/auth/me/mfa/totp/confirm/route.ts
index 2aac38635..6f30c6c6d 100644
--- a/src/app/api/auth/me/mfa/totp/confirm/route.ts
+++ b/src/app/api/auth/me/mfa/totp/confirm/route.ts
@@ -33,6 +33,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
+import { destroyOtherSessions } from "@/lib/auth/session";
import { decrypt } from "@/lib/crypto";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { verifyTotp } from "@/lib/auth/mfa/totp";
@@ -110,6 +111,12 @@ export const POST = apiHandler(async (req: Request) => {
}
const recoveryCodes = generateRecoveryCodes();
+ // A confirmed TOTP secret was refused above, so the only second factor the
+ // account can already hold is a security key.
+ const hadSecondFactor =
+ (await prisma.webauthnMfaCredential.count({
+ where: { userId: user.id },
+ })) > 0;
// The code verified and the activation is next — spend the elevation here so
// a wrong code above costs the caller nothing.
@@ -141,6 +148,19 @@ export const POST = apiHandler(async (req: Request) => {
// admin-enforced forced-enrollment redirect must clear immediately.
await setMfaEnrollCookie(false);
+ // Turning on the first second factor ends every other sign-in, as a password
+ // change does: a session opened before the factor existed, perhaps by
+ // whoever made the owner turn it on, must not keep going without it.
+ if (!hadSecondFactor) {
+ await destroyOtherSessions(
+ user.id,
+ auth.transport === "cookie"
+ ? { kind: "session", sessionId: auth.session.id }
+ : { kind: "accessToken", accessTokenHash: auth.accessTokenHash },
+ { reach: "sign-ins" },
+ );
+ }
+
await auditLog("auth.mfa.enabled", {
userId: user.id,
ipAddress: getClientIp(req),
diff --git a/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts
index e96388a1f..975731d76 100644
--- a/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts
+++ b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts
@@ -19,6 +19,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
+import { destroyOtherSessions } from "@/lib/auth/session";
import { verifyMfaRegistration } from "@/lib/auth/mfa/webauthn";
import { mfaWebauthnRegisterVerifySchema } from "@/lib/validations/mfa";
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
@@ -57,6 +58,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
// failed ceremony above must not have cost the caller their elevation.
await auth.commitElevation();
+ const hadSecondFactor =
+ user.totpConfirmedAt !== null ||
+ (await prisma.webauthnMfaCredential.count({
+ where: { userId: user.id },
+ })) > 0;
+
const { registrationInfo } = verification;
const created = await prisma.webauthnMfaCredential.create({
data: {
@@ -76,6 +83,19 @@ export const POST = apiHandler(async (request: NextRequest) => {
// so clear any forced-enrollment redirect immediately.
await setMfaEnrollCookie(false);
+ // Turning on the first second factor ends every other sign-in, as a password
+ // change does: a session opened before the factor existed, perhaps by
+ // whoever made the owner turn it on, must not keep going without it.
+ if (!hadSecondFactor) {
+ await destroyOtherSessions(
+ user.id,
+ auth.transport === "cookie"
+ ? { kind: "session", sessionId: auth.session.id }
+ : { kind: "accessToken", accessTokenHash: auth.accessTokenHash },
+ { reach: "sign-ins" },
+ );
+ }
+
await auditLog("auth.mfa.webauthn.register", {
userId: user.id,
ipAddress: getClientIp(request),
diff --git a/src/app/api/auth/me/sessions/__tests__/route.test.ts b/src/app/api/auth/me/sessions/__tests__/route.test.ts
index 471e38b04..d19634e43 100644
--- a/src/app/api/auth/me/sessions/__tests__/route.test.ts
+++ b/src/app/api/auth/me/sessions/__tests__/route.test.ts
@@ -163,16 +163,21 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
vi.mocked(destroyOtherSessions).mockResolvedValue({
sessionsRevoked: 3,
accessTokensRevoked: 0,
+ connectorsRevoked: 0,
+ shareLinksRevoked: 0,
});
const res = await DELETE(del());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.sessionsRevoked).toBe(3);
- expect(destroyOtherSessions).toHaveBeenCalledWith("user-1", {
- kind: "session",
- sessionId: "sess-current",
- });
+ expect(destroyOtherSessions).toHaveBeenCalledWith(
+ "user-1",
+ { kind: "session", sessionId: "sess-current" },
+ // Everything, share links included, when the client says nothing: the
+ // shipped app sends no parameter and gets the safe default.
+ { reach: "everything", keepShareLinks: false },
+ );
});
it("names a Bearer caller by its access token so its own device login is spared", async () => {
@@ -198,15 +203,18 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
vi.mocked(destroyOtherSessions).mockResolvedValue({
sessionsRevoked: 1,
accessTokensRevoked: 1,
+ connectorsRevoked: 0,
+ shareLinksRevoked: 0,
});
const res = await DELETE(del());
expect(res.status).toBe(200);
- expect(destroyOtherSessions).toHaveBeenCalledWith("user-1", {
- kind: "accessToken",
- accessTokenHash: hashToken(raw),
- });
+ expect(destroyOtherSessions).toHaveBeenCalledWith(
+ "user-1",
+ { kind: "accessToken", accessTokenHash: hashToken(raw) },
+ { reach: "everything", keepShareLinks: false },
+ );
vi.mocked(headers).mockResolvedValue({ get: () => null } as never);
});
});
diff --git a/src/app/api/auth/me/sessions/route.ts b/src/app/api/auth/me/sessions/route.ts
index ee4ade61c..1b9b02c34 100644
--- a/src/app/api/auth/me/sessions/route.ts
+++ b/src/app/api/auth/me/sessions/route.ts
@@ -1,8 +1,11 @@
/**
* GET /api/auth/me/sessions — list the user's active web sessions.
* DELETE /api/auth/me/sessions — "sign out everywhere": revoke every OTHER
- * session (and native refresh tokens), keeping
- * the caller's current session. Closes #64.
+ * session, device login, AI-assistant
+ * connection and token, and (unless
+ * `?keepShareLinks=1`) every clinician share
+ * link, keeping the caller's current
+ * credential. Closes #64.
*
* v1.23 — the user-facing session/device-management surface. Distinct from
* `/api/auth/me/devices`, which lists APNs / Web-Push notification devices;
@@ -114,15 +117,35 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
}
: { kind: "session", sessionId: auth.session.id };
- const { sessionsRevoked, accessTokensRevoked } = await destroyOtherSessions(
- user.id,
- current,
- );
+ // "Everywhere" reaches every credential that works without signing in: AI
+ // assistant connections, programmatic tokens and, unless the caller asks to
+ // keep them, clinician share links. The default is to revoke, because the
+ // person pressing this is often doing it after losing a device or a session,
+ // and a share link made by whoever held it would otherwise keep working. A
+ // client that sends nothing (the shipped app) gets the safe default.
+ const keepShareLinks =
+ new URL(request.url).searchParams.get("keepShareLinks") === "1";
+
+ const {
+ sessionsRevoked,
+ accessTokensRevoked,
+ connectorsRevoked,
+ shareLinksRevoked,
+ } = await destroyOtherSessions(user.id, current, {
+ reach: "everything",
+ keepShareLinks,
+ });
await auditLog("auth.session.revoke_others", {
userId: user.id,
ipAddress: getClientIp(request),
- details: { sessionsRevoked, accessTokensRevoked },
+ details: {
+ sessionsRevoked,
+ accessTokensRevoked,
+ connectorsRevoked,
+ shareLinksRevoked,
+ keepShareLinks,
+ },
});
annotate({
@@ -130,10 +153,17 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
meta: {
sessions_revoked: sessionsRevoked,
access_tokens_revoked: accessTokensRevoked,
+ connectors_revoked: connectorsRevoked,
+ share_links_revoked: shareLinksRevoked,
},
});
- return apiSuccess({ sessionsRevoked });
+ return apiSuccess({
+ sessionsRevoked,
+ accessTokensRevoked,
+ connectorsRevoked,
+ shareLinksRevoked,
+ });
});
/**
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/security-sessions-card.tsx b/src/components/settings/security-sessions-card.tsx
index 206a743f1..fa8310217 100644
--- a/src/components/settings/security-sessions-card.tsx
+++ b/src/components/settings/security-sessions-card.tsx
@@ -16,6 +16,7 @@ import { ChevronDown, Loader2, MonitorSmartphone } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
+import { Checkbox } from "@/components/ui/checkbox";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorRow } from "@/components/ui/query-error-row";
@@ -43,6 +44,11 @@ export function SecuritySessionsCard({
const fmt = useFormatters();
const queryClient = useQueryClient();
const [status, setStatus] = useState(null);
+ // "Everywhere" also ends clinician share links unless the person keeps
+ // them. Revoking is the default: this is often pressed after losing a
+ // device or a session, and a link made by whoever held it opens the record
+ // without signing in.
+ const [endShareLinks, setEndShareLinks] = useState(true);
// Collapsed by default — the list opens only when the user asks for it, so
// the security surface stays calm and skimmable on first paint. UI-only
// state; nothing is persisted across reloads.
@@ -69,7 +75,11 @@ export function SecuritySessionsCard({
const revokeOthers = useMutation({
mutationFn: () =>
- apiDelete<{ sessionsRevoked: number }>("/api/auth/me/sessions"),
+ apiDelete<{ sessionsRevoked: number }>(
+ endShareLinks
+ ? "/api/auth/me/sessions"
+ : "/api/auth/me/sessions?keepShareLinks=1",
+ ),
onSuccess: (res) => {
setStatus(
t("settings.security.signOutEverywhereDone", {
@@ -85,7 +95,6 @@ export function SecuritySessionsCard({
});
const sessions = data?.sessions ?? [];
- const hasOthers = sessions.some((s) => !s.isCurrent);
return (
@@ -185,7 +194,7 @@ export function SecuritySessionsCard({
)}
- {hasOthers && (
+ {data && (
revokeOthers.mutate()}
pending={revokeOthers.isPending}
+ extra={
+
+ }
/>
)}
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}