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}
{ diff --git a/src/components/ui/confirm-button.tsx b/src/components/ui/confirm-button.tsx index b89f1b13f..108f36f49 100644 --- a/src/components/ui/confirm-button.tsx +++ b/src/components/ui/confirm-button.tsx @@ -52,6 +52,8 @@ interface ConfirmCopy { pending?: boolean; /** `data-slot` for tests and the design guards. */ slot?: string; + /** A control that belongs with the question, such as an opt-out checkbox. */ + extra?: ReactNode; } function ConfirmBody({ @@ -61,6 +63,7 @@ function ConfirmBody({ onConfirm, pending = false, slot, + extra, }: ConfirmCopy) { const { t } = useTranslations(); @@ -70,6 +73,7 @@ function ConfirmBody({ {title} {body} + {extra} {t("common.cancel")} ); diff --git a/src/lib/__tests__/get-client-ip.test.ts b/src/lib/__tests__/get-client-ip.test.ts index 9e5a92d0e..56b082589 100644 --- a/src/lib/__tests__/get-client-ip.test.ts +++ b/src/lib/__tests__/get-client-ip.test.ts @@ -72,7 +72,7 @@ describe("getClientIp trusted-proxy semantics (V3 audit)", () => { }, ); - it("with TRUST_PROXY_HOPS=0 ignores XFF entirely", () => { + it("with TRUST_PROXY_HOPS=0 trusts no forwarding header, x-real-ip included", () => { process.env.TRUST_PROXY_HOPS = "0"; const ip = getClientIp( makeRequest({ @@ -80,7 +80,17 @@ describe("getClientIp trusted-proxy semantics (V3 audit)", () => { "x-real-ip": "8.8.8.8", }), ); - expect(ip).toBe("8.8.8.8"); + expect(ip).toBeNull(); + }); + + it("x-real-ip rotation — with no declared proxy a caller cannot mint a fresh bucket per request", () => { + process.env.TRUST_PROXY_HOPS = "0"; + const seen = new Set( + ["1.1.1.1", "2.2.2.2", "3.3.3.3"].map((ip) => + getClientIp(makeRequest({ "x-real-ip": ip })), + ), + ); + expect(seen).toEqual(new Set([null])); }); it("with TRUST_PROXY_HOPS=0 and no x-real-ip returns null", () => { @@ -277,7 +287,8 @@ describe("getClientIpOrTrustWarning shape (F-6, 2026-05-16)", () => { expect(result.ip).toBeNull(); }); - it("returns trustViolation=true and x-real-ip when chain is short but x-real-ip is set", () => { + it("returns trustViolation=true and ignores x-real-ip when the chain is short", () => { + // The declared proxies were bypassed, so x-real-ip came from the caller. process.env.TRUST_PROXY_HOPS = "2"; const result = getClientIpOrTrustWarning( makeRequest({ @@ -285,7 +296,7 @@ describe("getClientIpOrTrustWarning shape (F-6, 2026-05-16)", () => { "x-real-ip": "9.9.9.9", }), ); - expect(result).toEqual({ ip: "9.9.9.9", trustViolation: true }); + expect(result).toEqual({ ip: null, trustViolation: true }); }); it("returns trustViolation=false when XFF is absent (no chain to validate)", () => { diff --git a/src/lib/__tests__/rate-limit-auth-surface.test.ts b/src/lib/__tests__/rate-limit-auth-surface.test.ts index 8331e5479..f6b7369c3 100644 --- a/src/lib/__tests__/rate-limit-auth-surface.test.ts +++ b/src/lib/__tests__/rate-limit-auth-surface.test.ts @@ -176,8 +176,9 @@ describe("checkAuthSurfaceRateLimit — trust-violation tightening", () => { 5, 15 * 60 * 1000, ); - // Falls through to x-real-ip when the chain is too short. - expect(result.ip).toBe("9.9.9.9"); + // A short chain means the declared proxies were bypassed, so the + // caller-set x-real-ip is not believed either. + expect(result.ip).toBeNull(); expect(lastKey()).toBe("auth:anon:trust-violation"); } finally { warn.mockRestore(); diff --git a/src/lib/__tests__/require-recent-proof.test.ts b/src/lib/__tests__/require-recent-proof.test.ts new file mode 100644 index 000000000..5f3716fb2 --- /dev/null +++ b/src/lib/__tests__/require-recent-proof.test.ts @@ -0,0 +1,221 @@ +/** + * `requireRecentProof` / `assertRecentCookieProof` — the fresh-proof gate in + * front of whole-record exports, share links, token minting and the admin data + * actions. + * + * Cookie: an account with a second factor needs `mfaVerifiedAt` inside five + * minutes and nothing else; one without accepts a recent sign-in, second-factor + * stamp or password re-proof. Bearer: the rule the route names. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + session: { findUnique: vi.fn() }, + apiToken: { findUnique: vi.fn(), update: vi.fn() }, + user: { findUnique: vi.fn() }, + webauthnMfaCredential: { count: vi.fn() }, + passkey: { count: vi.fn() }, + stepUpElevation: { findUnique: vi.fn() }, + $queryRaw: vi.fn(), + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn(() => "hash") })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +const headersGet = vi.fn<(name: string) => string | null>(); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: headersGet })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { requireRecentProof, StepUpRequiredError } from "../api-handler"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { headers } from "next/headers"; +import { auditLog } from "@/lib/auth/audit"; + +const MFA_USER = { + id: "user-1", + role: "USER" as const, + username: "u", + totpConfirmedAt: new Date("2020-01-01"), + passwordHash: "argon", +}; +const PLAIN_USER = { ...MFA_USER, totpConfirmedAt: null }; + +const NOW = () => new Date(); +const OLD = () => new Date(Date.now() - 10 * 60 * 1000); + +beforeEach(() => { + vi.resetAllMocks(); + headersGet.mockReturnValue(null); + vi.mocked(headers).mockImplementation( + async () => ({ get: headersGet }) as never, + ); + vi.mocked(auditLog).mockResolvedValue(undefined); + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never); + vi.mocked(prisma.passkey.count).mockResolvedValue(0 as never); +}); + +function cookie( + user: unknown, + stamps: { + mfaVerifiedAt?: Date | null; + createdAt?: Date; + reproofAt?: Date | null; + }, +) { + vi.mocked(getSession).mockResolvedValue({ + session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) }, + user, + } as never); + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + mfaVerifiedAt: stamps.mfaVerifiedAt ?? null, + createdAt: stamps.createdAt ?? OLD(), + reproofAt: stamps.reproofAt ?? null, + } as never); +} + +function bearer(user: unknown, elevation: string | null = null) { + vi.mocked(getSession).mockResolvedValue(null as never); + headersGet.mockImplementation((n) => { + const name = n.toLowerCase(); + if (name === "authorization") return "Bearer hlk_xyz"; + if (name === "x-step-up") return elevation; + return null; + }); + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "tok-1", + userId: "user-1", + permissions: ["*"], + revoked: false, + expiresAt: null, + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(user as never); + vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never); +} + +async function refusal(p: Promise): Promise { + try { + await p; + } catch (err) { + expect(err).toBeInstanceOf(StepUpRequiredError); + return err as StepUpRequiredError; + } + throw new Error("expected a refusal"); +} + +describe("cookie session, account without a second factor", () => { + it("passes on a sign-in inside five minutes", async () => { + cookie(PLAIN_USER, { createdAt: NOW() }); + await expect( + requireRecentProof({ bearer: "elevation" }), + ).resolves.toBeTruthy(); + }); + + it("passes on a password re-proof inside five minutes", async () => { + cookie(PLAIN_USER, { reproofAt: NOW() }); + await expect( + requireRecentProof({ bearer: "elevation" }), + ).resolves.toBeTruthy(); + }); + + it("refuses an old session and names the password as the way through", async () => { + cookie(PLAIN_USER, {}); + const err = await refusal(requireRecentProof({ bearer: "token" })); + expect(err.errorCode).toBe("auth.reproof.required"); + expect(err.extraMeta).toEqual({ methods: ["password"] }); + }); + + it("offers a passkey when the account holds one", async () => { + cookie({ ...PLAIN_USER, passwordHash: null }, {}); + vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never); + const err = await refusal(requireRecentProof({ bearer: "token" })); + expect(err.extraMeta).toEqual({ methods: ["passkey"] }); + }); +}); + +describe("cookie session, account with a second factor", () => { + it("passes on a second factor inside five minutes", async () => { + cookie(MFA_USER, { mfaVerifiedAt: NOW() }); + await expect( + requireRecentProof({ bearer: "elevation" }), + ).resolves.toBeTruthy(); + }); + + it("a password re-proof does not stand in for the second factor", async () => { + cookie(MFA_USER, { reproofAt: NOW() }); + const err = await refusal(requireRecentProof({ bearer: "elevation" })); + expect(err.extraMeta).toEqual({ methods: ["totp"] }); + }); + + it("nor does a fresh session without the factor (a remembered device)", async () => { + cookie(MFA_USER, { createdAt: NOW() }); + await refusal(requireRecentProof({ bearer: "elevation" })); + }); + + it("refuses a stale second-factor stamp", async () => { + cookie(MFA_USER, { mfaVerifiedAt: OLD() }); + await refusal(requireRecentProof({ bearer: "elevation" })); + }); +}); + +describe("Bearer", () => { + it("`elevation` refuses a token without one", async () => { + bearer(PLAIN_USER); + const err = await refusal(requireRecentProof({ bearer: "elevation" })); + expect(err.errorCode).toBe("auth.stepup.required"); + }); + + it("`elevation-if-enrolled` passes a token on an account without a second factor", async () => { + bearer(PLAIN_USER); + await expect( + requireRecentProof({ bearer: "elevation-if-enrolled" }), + ).resolves.toBeTruthy(); + }); + + it("`elevation-if-enrolled` refuses a bare token on an enrolled account", async () => { + bearer(MFA_USER); + await refusal(requireRecentProof({ bearer: "elevation-if-enrolled" })); + }); + + it("`token` passes on the token alone", async () => { + bearer(MFA_USER); + await expect(requireRecentProof({ bearer: "token" })).resolves.toBeTruthy(); + }); + + it("`elevation` accepts a password-proved elevation on an account without a second factor", async () => { + bearer(PLAIN_USER, `hle_${"a".repeat(64)}`); + vi.mocked(prisma.stepUpElevation.findUnique).mockResolvedValue({ + userId: "user-1", + apiTokenId: "tok-1", + consumedAt: null, + expiresAt: new Date(Date.now() + 60_000), + method: "password", + } as never); + const auth = await requireRecentProof({ bearer: "elevation" }); + expect(typeof auth.commitElevation).toBe("function"); + }); + + it("`elevation` refuses a password-proved elevation on an enrolled account", async () => { + bearer(MFA_USER, `hle_${"a".repeat(64)}`); + vi.mocked(prisma.stepUpElevation.findUnique).mockResolvedValue({ + userId: "user-1", + apiTokenId: "tok-1", + consumedAt: null, + expiresAt: new Date(Date.now() + 60_000), + method: "password", + } as never); + await refusal(requireRecentProof({ bearer: "elevation" })); + }); +}); diff --git a/src/lib/__tests__/secret-shape.test.ts b/src/lib/__tests__/secret-shape.test.ts index 2cdbf3d26..3dfe578b8 100644 --- a/src/lib/__tests__/secret-shape.test.ts +++ b/src/lib/__tests__/secret-shape.test.ts @@ -9,6 +9,10 @@ describe("looksSecretShaped", () => { ["a share-link token", "see hls_def456"], ["an invite token", "hlv_0a1b2c3d"], ["an elevation token", "hle_0a1b2c3d"], + ["a native sign-in handoff code", "hlh_Ab9-xYz_12"], + ["an MCP authorization code", "hlac_eyJ0eXAi"], + ["an MCP refresh token", "hlrt_eyJ0eXAi"], + ["an MCP client id", "hlc_eyJ0eXAi"], ["an OpenAI key", "echoed sk-1234567890"], ["an Anthropic key", "sk-ant-api03-xyz12345"], ])("matches %s", (_label, text) => { diff --git a/src/lib/ai/capabilities/resolve.ts b/src/lib/ai/capabilities/resolve.ts index c661d78d3..60c48f119 100644 --- a/src/lib/ai/capabilities/resolve.ts +++ b/src/lib/ai/capabilities/resolve.ts @@ -13,6 +13,10 @@ * a client acted on it. They stay a per-call refusal with their own codes. */ import { documentProviderRank } from "@/lib/documents/provider-rank"; +import { + isExternalProvider, + isOperatorHeldProvider, +} from "@/lib/ai/provider-egress"; import type { ModuleKey } from "@/lib/modules/registry"; import type { ModuleAccessState } from "@/lib/sharing/module-disclosure"; @@ -70,14 +74,8 @@ export interface AiCapabilityInputs { recordKind: AiRecordKind; } -/** Provider tags that egress through a credential the operator holds. */ -const OPERATOR_HELD_PROVIDER_TYPES: ReadonlySet = new Set([ - "admin-openai", - "admin-codex", -]); - -/** The one provider tag that keeps its input on the machine. */ -const LOCAL_PROVIDER_TYPE = "local"; +// Which tags are operator-held and which stays local: `@/lib/ai/provider-egress`, +// the list the consent gate reads too. const REASON_RANK: ReadonlyMap = new Map( AI_UNAVAILABLE_REASONS.map((reason, index) => [reason, index]), @@ -208,18 +206,18 @@ function consentNeeded( // A cascade may fall through to any entry, so one operator-held entry // anywhere in the chain is enough to need the receipt. return provider.entries.some((entry) => - OPERATOR_HELD_PROVIDER_TYPES.has(entry.providerType), + isOperatorHeldProvider(entry.providerType), ); } if (def.modality === "document") { // Document reads call the one picked provider, no cascade. const pick = documentPick(def, provider); - return pick !== null && pick.providerType !== LOCAL_PROVIDER_TYPE; + return pick !== null && isExternalProvider(pick.providerType); } // Text under the document rule cascades like a snapshot does, so any entry // that leaves the machine needs the receipt. - return provider.entries.some( - (entry) => entry.providerType !== LOCAL_PROVIDER_TYPE, + return provider.entries.some((entry) => + isExternalProvider(entry.providerType), ); } diff --git a/src/lib/ai/consent-guard.ts b/src/lib/ai/consent-guard.ts index 85b462a84..062c65552 100644 --- a/src/lib/ai/consent-guard.ts +++ b/src/lib/ai/consent-guard.ts @@ -30,6 +30,10 @@ import { latestActiveReceipt } from "@/lib/consent/receipts"; import type { ConsentKind } from "@/lib/validations/consent"; import type { ProviderChainResolved } from "@/lib/ai/provider-runner"; +import { + isExternalProvider, + isOperatorHeldProvider, +} from "@/lib/ai/provider-egress"; /** * The two AI surfaces that egress PHI through a chain. Each maps to the consent @@ -39,17 +43,6 @@ import type { ProviderChainResolved } from "@/lib/ai/provider-runner"; */ export type ConsentSurface = "coach" | "insights"; -/** - * Provider tags that egress via an operator-managed credential the user did not - * personally contract: the operator's global OpenAI key (`admin-openai`) and the - * operator's shared central Codex / ChatGPT-subscription account (`admin-codex`). - * Both require an active consent receipt before any PHI leaves for them. - */ -const SERVER_MANAGED_PROVIDER_TYPES: ReadonlySet = new Set([ - "admin-openai", - "admin-codex", -]); - /** * Error thrown when an external-LLM egress on a server-managed key is * attempted without an active consent receipt. Mirrors the AI capability refusal @@ -79,9 +72,7 @@ export class ConsentRequiredError extends Error { export function chainRequiresServerManagedConsent( chain: ReadonlyArray, ): boolean { - return chain.some((entry) => - SERVER_MANAGED_PROVIDER_TYPES.has(entry.providerType), - ); + return chain.some((entry) => isOperatorHeldProvider(entry.providerType)); } /** The consent kinds that satisfy a given surface (the specific + the master). */ @@ -148,13 +139,11 @@ export async function assertConsentForChain(args: { * reads the receipt kinds from the capability table; the auto-read toggle is a * trigger, never the consent, so a revoked receipt wins over a toggle left on. */ -const LOCAL_ONLY_PROVIDER_TYPES: ReadonlySet = new Set(["local"]); - /** * True when reading a document through this provider egresses it OFF the machine * to a third-party AI service. Only the self-hosted `local` provider keeps the * document on the operator's own infrastructure. */ export function isExternalDocumentEgress(providerType: string): boolean { - return !LOCAL_ONLY_PROVIDER_TYPES.has(providerType); + return isExternalProvider(providerType); } diff --git a/src/lib/ai/provider-egress.ts b/src/lib/ai/provider-egress.ts new file mode 100644 index 000000000..615bf5d7b --- /dev/null +++ b/src/lib/ai/provider-egress.ts @@ -0,0 +1,36 @@ +/** + * Which AI provider tags send a record off the machine, and through whose + * credential. One definition, read by the consent gate + * (`src/lib/ai/consent-guard.ts`) and the capability resolver + * (`src/lib/ai/capabilities/resolve.ts`). They each kept their own copy + * before, and a provider added to one list but not the other would have been + * consented for in one place and not the other. + */ + +/** + * Provider tags that egress through a credential the operator holds, which + * the user did not personally contract: the operator's global OpenAI key + * (`admin-openai`) and the operator's shared Codex / ChatGPT-subscription + * account (`admin-codex`). Both need an active consent receipt before any + * health data leaves for them. + */ +export const OPERATOR_HELD_PROVIDER_TYPES: ReadonlySet = new Set([ + "admin-openai", + "admin-codex", +]); + +/** The one provider tag that keeps its input on the operator's machine. */ +export const LOCAL_PROVIDER_TYPE = "local"; + +export function isOperatorHeldProvider(providerType: string): boolean { + return OPERATOR_HELD_PROVIDER_TYPES.has(providerType); +} + +/** + * True when this provider sends its input OFF the machine to a third-party AI + * service. Only the self-hosted `local` provider keeps it on the operator's + * own infrastructure. + */ +export function isExternalProvider(providerType: string): boolean { + return providerType !== LOCAL_PROVIDER_TYPE; +} diff --git a/src/lib/ai/server-provider-health.ts b/src/lib/ai/server-provider-health.ts index b4ba030cb..81a991b6d 100644 --- a/src/lib/ai/server-provider-health.ts +++ b/src/lib/ai/server-provider-health.ts @@ -40,6 +40,7 @@ */ import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; +import { OPERATOR_HELD_PROVIDER_TYPES } from "@/lib/ai/provider-egress"; /** * The whole contract. Three words, no payload. @@ -68,11 +69,11 @@ export const SERVER_PROVIDER_FRESH_WINDOW_MS = 24 * 60 * 60 * 1000; /** * The provider tags the operator pays for: the instance-wide OpenAI key and - * the shared central Codex account. These are exactly the two tags - * `consent-guard.ts` gates on, and that is not a coincidence — the offer - * this feeds is the consent for these two and for nothing else. + * the shared central Codex account. The same set `consent-guard.ts` gates on, + * read from the one definition, because the offer this feeds is the consent + * for these and for nothing else. */ -const SHARED_PROVIDER_TYPES = ["admin-openai", "admin-codex"] as const; +const SHARED_PROVIDER_TYPES = [...OPERATOR_HELD_PROVIDER_TYPES]; export async function readServerProviderHealth( now: Date = new Date(), @@ -84,7 +85,7 @@ export async function readServerProviderHealth( }>; try { rows = await prisma.providerHealth.findMany({ - where: { providerType: { in: [...SHARED_PROVIDER_TYPES] } }, + where: { providerType: { in: SHARED_PROVIDER_TYPES } }, // Three columns, none of which identifies an account. The absence of // `userId` here is load-bearing, not tidiness. select: { lastResult: true, lastOkAt: true, nextRetryAt: true }, diff --git a/src/lib/api-errors.ts b/src/lib/api-errors.ts index 29aaf9a22..d891d3af1 100644 --- a/src/lib/api-errors.ts +++ b/src/lib/api-errors.ts @@ -153,6 +153,13 @@ export class RecordSessionChangedError extends SharingAuthError { } } +/** + * The session carries no proof recent enough for the action, and the request + * brought none. The client asks for one (`POST /api/auth/reproof`, or a proof + * in the body on the enrollment routes) and retries. + */ +export const REPROOF_REQUIRED_CODE = "auth.reproof.required"; + /** * Error thrown when a step-up gate is not satisfied. Carries `errorCode` so * the route can surface a stable machine code (`auth.stepup.required`) the @@ -163,6 +170,13 @@ export class StepUpRequiredError extends HttpError { constructor( public errorCode: string = "auth.stepup.required", message = "Recent second-factor verification required", + /** + * Extra machine fields for the envelope's `meta`, beside `errorCode`. The + * recent-proof gate names the proofs the account can give here + * (`methods`), so the client offers exactly those and nothing it would + * then refuse. + */ + public extraMeta: Record = {}, ) { super(401, message); this.name = "StepUpRequiredError"; diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 37c0c1863..db1087f5a 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -93,6 +93,7 @@ import { SharingNotPermittedError, RecordSessionChangedError, StepUpRequiredError, + REPROOF_REQUIRED_CODE, } from "./api-errors"; export { @@ -289,7 +290,7 @@ export function apiHandler Promise>( { data: null, error: error.message, - meta: { errorCode: error.errorCode }, + meta: { ...error.extraMeta, errorCode: error.errorCode }, }, { status: error.statusCode }, ); @@ -1387,6 +1388,151 @@ export async function requireFreshMfaOrElevationIfEnrolled( return { ...auth, commitElevation }; } +/** + * A proof the recent-proof gate can ask for. `password` and `passkey` on an + * account without a second factor; `totp`, `webauthn` and `passkey` on one + * with a second factor, where a password alone has never been enough. + */ +export type RecentProofMethod = "password" | "totp" | "webauthn" | "passkey"; + +/** + * How the recent-proof gate treats a Bearer caller. + * + * `elevation` — the Bearer twin of the cookie rule: an `X-Step-Up` elevation + * minted by `POST /api/auth/step-up`. On an account with a second factor it + * must come from a second-factor proof; without one, a password-proved + * elevation is enough. For routes no shipped client calls on Bearer. + * + * `elevation-if-enrolled` — the erasure routes' rule + * (`requireFreshMfaOrElevationIfEnrolled`): an account with a second factor + * presents a second-factor elevation, one without passes on the token. For a + * route the shipped app calls, where the second-factor arm was already + * refused before and the other arm must keep working. + * + * `token` — the token is the proof, as before. Only for routes the shipped + * app calls without an elevation, where a refusal would sign the app out (it + * reads a 401 outside its known paths as a dead session). Each one is named + * in the iOS note and in `recent-proof-surface-guard.test.ts`, so tightening + * it later is a visible edit. + */ +export type RecentProofBearerRule = + "elevation" | "elevation-if-enrolled" | "token"; + +/** + * The fresh-proof gate in front of the actions that hand the record, or a + * standing credential to it, to somebody: exporting the whole record, making a + * clinician share link, minting a token, and the admin backup, restore, wipe + * and reset actions. A live session is not enough for those, because whoever + * holds a stolen session would otherwise walk off with the record or leave a + * credential behind that outlives the session. + * + * Cookie session, account WITH a second factor: `Session.mfaVerifiedAt` + * within five minutes — exactly `requireFreshMfa`. Signing in completes the + * factor; later, `POST /api/auth/reproof` with a TOTP code, security key or + * passkey stamps it again. + * + * Cookie session, account WITHOUT a second factor: any of the session's + * creation (a sign-in), `mfaVerifiedAt` (a passkey sign-in or re-proof) or + * `reproofAt` (a password re-proof) within five minutes. Such an account + * re-enters its password, or uses a passkey. + * + * The refusal is 401 `auth.reproof.required` with `meta.methods`, the proofs + * this account can give; the web opens the re-proof dialog on it and retries. + * An empty list means the account holds nothing to re-prove in place (an + * SSO-only account), and signing in again is the way through. + * + * Returns the caller plus `commitElevation`, which spends a Bearer elevation + * and is a no-op otherwise. Call it right before acting. + */ +export async function requireRecentProof(options: { + bearer: RecentProofBearerRule; +}): Promise { + const auth = await requireAuth(); + const noop = async () => {}; + + if (auth.authMethod === "cookie") { + await assertRecentCookieProof(auth.user, auth.session.id); + return { ...auth, commitElevation: noop }; + } + + const enrolled = await hasSecondFactorEnrolled(auth.user); + if (options.bearer === "token") { + annotate({ meta: { recent_proof: "bearer_token" } }); + return { ...auth, commitElevation: noop }; + } + if (options.bearer === "elevation-if-enrolled" && !enrolled) { + annotate({ meta: { recent_proof: "bearer_token" } }); + return { ...auth, commitElevation: noop }; + } + const commitElevation = await resolveBearerElevation( + { user: auth.user, apiTokenId: auth.session.id }, + { freshFactor: enrolled, proofSource: "second-factor" }, + ); + return { ...auth, commitElevation }; +} + +/** + * The cookie arm of {@link requireRecentProof}, for routes that resolve the + * caller themselves (the admin routes, which must stay on `requireAdmin`). + */ +export async function assertRecentCookieProof( + user: User, + sessionId: string, +): Promise { + const enrolled = await hasSecondFactorEnrolled(user); + const row = await prisma.session.findUnique({ + where: { id: sessionId }, + select: { mfaVerifiedAt: true, createdAt: true, reproofAt: true }, + }); + const now = Date.now(); + const fresh = (d: Date | null | undefined) => + d != null && now - d.getTime() <= MFA_STEP_UP_MAX_AGE_SECONDS * 1000; + const satisfied = enrolled + ? fresh(row?.mfaVerifiedAt) + : fresh(row?.mfaVerifiedAt) || + fresh(row?.createdAt) || + fresh(row?.reproofAt); + if (satisfied) { + annotate({ meta: { recent_proof: "session" } }); + return; + } + const methods = await recentProofMethods(user, enrolled); + annotate({ + action: { name: "auth.reproof.required" }, + meta: { second_factor: enrolled, methods: methods.join(",") }, + }); + throw new StepUpRequiredError( + REPROOF_REQUIRED_CODE, + "Confirm it is you to continue", + { methods }, + ); +} + +/** + * The proofs that would satisfy {@link assertRecentCookieProof} for this + * account. Also what `POST /api/auth/reproof` accepts, so the dialog and the + * endpoint cannot disagree. + */ +export async function recentProofMethods( + user: Pick, + enrolled?: boolean, +): Promise { + const hasSecond = enrolled ?? (await hasSecondFactorEnrolled(user)); + const [keys, passkeys] = await Promise.all([ + prisma.webauthnMfaCredential.count({ where: { userId: user.id } }), + prisma.passkey.count({ where: { userId: user.id } }), + ]); + const methods: RecentProofMethod[] = []; + if (hasSecond) { + if (user.totpConfirmedAt) methods.push("totp"); + if (keys > 0) methods.push("webauthn"); + } else if (user.passwordHash) { + methods.push("password"); + } + if (passkeys > 0) methods.push("passkey"); + return methods; +} + /** * Does this account have a SECOND FACTOR at all? * diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts index b54276608..adcbf113c 100644 --- a/src/lib/api-response.ts +++ b/src/lib/api-response.ts @@ -390,9 +390,9 @@ export async function safeJson( * IP-based rate-limits. * * Trust model (`TRUST_PROXY_HOPS` env): - * - "0" → ignore XFF entirely, fall back to x-real-ip / null + * - "0" → trust no forwarding header at all, XFF or x-real-ip * (use this if HealthLog is internet-facing without a - * reverse proxy you control) + * reverse proxy you control); the IP resolves to null * - "1" (default)→ trust exactly one hop (typical Coolify / Caddy / * Cloudflare-Tunnel single-proxy deployment); read the * rightmost XFF entry which is the IP your proxy @@ -540,18 +540,24 @@ export function getClientIpOrTrustWarning(request: Request): { // configured trust. Without this warning the silent degrade was // invisible until rate-limits visibly misfired in production. warnTrustViolationOnce(hops, chain.length); - const realIp = request.headers.get("x-real-ip"); - return { - ip: realIp && looksLikeIp(realIp) ? realIp : null, - trustViolation: true, - }; + // Not x-real-ip either. A chain shorter than the configured hops means + // the request did not come through the proxies the operator declared, + // so whatever x-real-ip it carries was set by the caller. + return { ip: null, trustViolation: true }; } + // x-real-ip is a forwarding header like XFF and gets the same trust: only + // when the operator declares a proxy in front (hops > 0), and only as the + // single-proxy stand-in for a missing XFF. It used to be read on every + // path, including `TRUST_PROXY_HOPS=0` ("no proxy, trust no header") and + // the broken-chain path above, so a caller sending a fresh x-real-ip per + // request got a fresh per-IP rate-limit bucket each time. + const realIp = request.headers.get("x-real-ip")?.trim(); + return { + ip: realIp && looksLikeIp(realIp) ? realIp : null, + trustViolation: false, + }; } - const realIp = request.headers.get("x-real-ip"); - return { - ip: realIp && looksLikeIp(realIp) ? realIp : null, - trustViolation: false, - }; + return { ip: null, trustViolation: false }; } export function getClientIp(request: Request): string | null { diff --git a/src/lib/auth/__tests__/passkey.test.ts b/src/lib/auth/__tests__/passkey.test.ts index d7edcda31..e4877a0e9 100644 --- a/src/lib/auth/__tests__/passkey.test.ts +++ b/src/lib/auth/__tests__/passkey.test.ts @@ -14,6 +14,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { + $queryRaw: vi.fn(), authChallenge: { findUnique: vi.fn(), deleteMany: vi.fn(), @@ -57,6 +58,9 @@ const VALID_BODY = { }; beforeEach(() => { + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { challenge: "challenge", user_id: null }, + ] as never); vi.mocked(prisma.authChallenge.findUnique).mockResolvedValue({ id: "ch-1", challenge: "challenge", diff --git a/src/lib/auth/__tests__/refresh-token.test.ts b/src/lib/auth/__tests__/refresh-token.test.ts index 943539135..87461fdd9 100644 --- a/src/lib/auth/__tests__/refresh-token.test.ts +++ b/src/lib/auth/__tests__/refresh-token.test.ts @@ -509,10 +509,9 @@ describe("rotateRefreshToken", () => { expect(legitimate.ok).toBe(true); }); - it("still rotates when the caller sends no device id at all", async () => { - // An older client that never sends `X-Device-Id` must not be locked out - // by the device binding — a presented null is unattributable, not a - // mismatch. + it("refuses a device-bound token presented with no device id", async () => { + // Leaving the header out must not be a way round the device binding: a + // token issued to dev-1 is rotated only by a caller that says dev-1. const dev1 = await issueAccessAndRefresh({ userId: "u1", policy: NATIVE_POLICY, @@ -525,6 +524,26 @@ describe("rotateRefreshToken", () => { policy: NATIVE_POLICY, }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe("device_mismatch"); + expect(dbState.refreshTokens[0].usedAt).toBeNull(); + }); + + it("still rotates a token issued without a device id, header or not", async () => { + // An older client that never sent `X-Device-Id` holds rows with no stored + // id, and nothing to compare means nothing to refuse. + const legacy = await issueAccessAndRefresh({ + userId: "u1", + policy: NATIVE_POLICY, + source: "login.password", + }); + + const result = await rotateRefreshToken({ + refreshToken: legacy.refreshToken, + policy: NATIVE_POLICY, + }); + expect(result.ok).toBe(true); }); diff --git a/src/lib/auth/__tests__/session.test.ts b/src/lib/auth/__tests__/session.test.ts index b7b169611..4ffeaf403 100644 --- a/src/lib/auth/__tests__/session.test.ts +++ b/src/lib/auth/__tests__/session.test.ts @@ -48,6 +48,9 @@ vi.mock("@/lib/db", () => ({ apiToken: { updateMany: vi.fn() }, refreshToken: { updateMany: vi.fn() }, trustedDevice: { deleteMany: vi.fn() }, + mcpOAuthConnection: { updateMany: vi.fn() }, + clinicianShareLink: { updateMany: vi.fn() }, + stepUpElevation: { deleteMany: vi.fn() }, $transaction: vi.fn(async (ops: Promise[]) => Promise.all(ops)), }, })); @@ -333,6 +336,14 @@ describe("destroyAllSessions", () => { where: { userId: "user-rotated", revokedAt: null }, data: { revokedAt: expect.any(Date) }, }); + expect(prisma.mcpOAuthConnection.updateMany).toHaveBeenCalledWith({ + where: { userId: "user-rotated", revokedAt: null }, + data: { revokedAt: expect.any(Date) }, + }); + expect(prisma.clinicianShareLink.updateMany).toHaveBeenCalledWith({ + where: { userId: "user-rotated", revokedAt: null }, + data: { revokedAt: expect.any(Date) }, + }); expect(prisma.$transaction).toHaveBeenCalledTimes(1); }); }); diff --git a/src/lib/auth/__tests__/webauthn-library-contract.test.ts b/src/lib/auth/__tests__/webauthn-library-contract.test.ts index a20e474a9..cfe07e234 100644 --- a/src/lib/auth/__tests__/webauthn-library-contract.test.ts +++ b/src/lib/auth/__tests__/webauthn-library-contract.test.ts @@ -63,6 +63,22 @@ vi.mock("@/lib/db", () => { ) => [...table.values()].find((r) => r.credentialId === credentialId) ?? null; return { prisma: { + // `claimAuthChallenge`'s guarded DELETE ... RETURNING, over the map: + // values are (id, type) or (id, type, userId). + $queryRaw: async (_sql: TemplateStringsArray, ...values: unknown[]) => { + const [id, type, userId] = values as [string, string, string?]; + const row = store.authChallenge.get(id); + if ( + !row || + row.type !== type || + (userId !== undefined && row.userId !== userId) || + !((row.expiresAt as Date) > new Date()) + ) { + return []; + } + store.authChallenge.delete(id); + return [{ challenge: row.challenge, user_id: row.userId ?? null }]; + }, authChallenge: { deleteMany: async () => ({ count: 0 }), create: async ({ data }: { data: Record }) => { diff --git a/src/lib/auth/existing-factor-proof.ts b/src/lib/auth/existing-factor-proof.ts index 64a9c3060..64915503d 100644 --- a/src/lib/auth/existing-factor-proof.ts +++ b/src/lib/auth/existing-factor-proof.ts @@ -19,6 +19,7 @@ import type { User } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { apiError } from "@/lib/api-response"; +import { REPROOF_REQUIRED_CODE } from "@/lib/api-errors"; import { auditLog } from "@/lib/auth/audit"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -164,7 +165,7 @@ export async function recordReproofFailure( } /** No proof was presented, and the session carries none recent enough. */ -export const REPROOF_REQUIRED_CODE = "auth.reproof.required"; +export { REPROOF_REQUIRED_CODE }; /** A proof was presented and did not verify. The session is untouched. */ export const REPROOF_FAILED_CODE = "auth.reproof.failed"; @@ -188,6 +189,9 @@ export const RECENT_PROOF_MAX_AGE_MS = 5 * 60 * 1000; * * - the session completed a second factor or passkey sign-in within the last * five minutes (`Session.mfaVerifiedAt`); + * - the session re-proved a credential at `POST /api/auth/reproof` within + * the last five minutes (`Session.reproofAt`), which accepts the same + * proofs this gate takes in the body; * - the session itself was created by a sign-in within the last five * minutes (`Session.createdAt`, which no later write touches) — this is * also what lets an SSO-only account with nothing else to re-prove enroll @@ -210,12 +214,16 @@ export async function checkCookieEnrollmentProof(args: { const row = await prisma.session.findUnique({ where: { id: sessionId }, - select: { mfaVerifiedAt: true, createdAt: true }, + select: { mfaVerifiedAt: true, createdAt: true, reproofAt: true }, }); const now = Date.now(); const recent = (d: Date | null | undefined) => d != null && now - d.getTime() <= RECENT_PROOF_MAX_AGE_MS; - if (recent(row?.mfaVerifiedAt) || recent(row?.createdAt)) { + if ( + recent(row?.mfaVerifiedAt) || + recent(row?.createdAt) || + recent(row?.reproofAt) + ) { annotate({ meta: { enrollment_proof: "recent_session" } }); return null; } @@ -316,12 +324,17 @@ export async function authorizeSensitiveChange(args: { if (cookieSessionId) { const row = await prisma.session.findUnique({ where: { id: cookieSessionId }, - select: { mfaVerifiedAt: true, createdAt: true }, + select: { mfaVerifiedAt: true, createdAt: true, reproofAt: true }, }); const now = Date.now(); const recent = (d: Date | null | undefined) => d != null && now - d.getTime() <= RECENT_PROOF_MAX_AGE_MS; - if (recent(row?.mfaVerifiedAt) || recent(row?.createdAt)) return "ok"; + if ( + recent(row?.mfaVerifiedAt) || + recent(row?.createdAt) || + recent(row?.reproofAt) + ) + return "ok"; } if (!currentPassword) return "required"; diff --git a/src/lib/auth/mfa/webauthn.ts b/src/lib/auth/mfa/webauthn.ts index 24c6b9ac2..691a5bd90 100644 --- a/src/lib/auth/mfa/webauthn.ts +++ b/src/lib/auth/mfa/webauthn.ts @@ -31,6 +31,7 @@ import { } from "@simplewebauthn/server"; import { z } from "zod/v4"; import { prisma } from "@/lib/db"; +import { claimAuthChallenge } from "@/lib/auth/passkey"; import { RP_NAME as rpName, getRpId, @@ -146,38 +147,30 @@ export async function verifyMfaRegistration( userId: string, response: unknown, ): Promise { - const challenge = await prisma.authChallenge.findUnique({ - where: { id: challengeId }, - }); - // The challenge must be live, of the registration type, and bound to the // calling user — a challenge minted for another account can never be reused. - if ( - !challenge || - challenge.expiresAt < new Date() || - challenge.type !== "mfa_registration" || - challenge.userId !== userId - ) { + // Claimed (deleted) in the same statement that checks it, so two concurrent + // requests cannot both use it. + const challenge = await claimAuthChallenge({ + challengeId, + type: "mfa_registration", + userId, + }); + if (!challenge) { throw new Error("Challenge expired or not found"); } - try { - const parsed = registrationResponseSchema.safeParse(response); - if (!parsed.success) { - throw new Error("Registration response shape invalid"); - } - return await verifyRegistrationResponse({ - response: parsed.data as RegistrationResponseJSON, - expectedChallenge: challenge.challenge, - expectedOrigin: getExpectedOrigin(), - expectedRPID: getRpId(), - supportedAlgorithmIDs: REGISTRATION_ACCEPTED_ALGORITHM_IDS, - }); - } finally { - await prisma.authChallenge - .delete({ where: { id: challengeId } }) - .catch(() => {}); + const parsed = registrationResponseSchema.safeParse(response); + if (!parsed.success) { + throw new Error("Registration response shape invalid"); } + return verifyRegistrationResponse({ + response: parsed.data as RegistrationResponseJSON, + expectedChallenge: challenge.challenge, + expectedOrigin: getExpectedOrigin(), + expectedRPID: getRpId(), + supportedAlgorithmIDs: REGISTRATION_ACCEPTED_ALGORITHM_IDS, + }); } // ── Authentication (mid-login second factor) ───────────────────────── @@ -232,16 +225,14 @@ export async function verifyMfaAuthentication( userId: string, response: unknown, ): Promise { - const challenge = await prisma.authChallenge.findUnique({ - where: { id: challengeId }, + // Claimed (deleted) in the same statement that checks it: one assertion, + // one verification, even when two requests carry it at once. + const challenge = await claimAuthChallenge({ + challengeId, + type: "mfa_authentication", + userId, }); - - if ( - !challenge || - challenge.expiresAt < new Date() || - challenge.type !== "mfa_authentication" || - challenge.userId !== userId - ) { + if (!challenge) { throw new Error("Challenge expired or not found"); } @@ -251,43 +242,37 @@ export async function verifyMfaAuthentication( } const typedResponse = parsed.data as unknown as AuthenticationResponseJSON; - try { - // The presented credential MUST belong to the password-identified user — - // look it up scoped to `userId` so an assertion against another account's - // key can never satisfy this account's second factor. - const credential = await prisma.webauthnMfaCredential.findFirst({ - where: { credentialId: typedResponse.id, userId }, + // The presented credential MUST belong to the password-identified user — + // look it up scoped to `userId` so an assertion against another account's + // key can never satisfy this account's second factor. + const credential = await prisma.webauthnMfaCredential.findFirst({ + where: { credentialId: typedResponse.id, userId }, + }); + if (!credential) return false; + + const verification: VerifiedAuthenticationResponse = + await verifyAuthenticationResponse({ + response: typedResponse, + expectedChallenge: challenge.challenge, + expectedOrigin: getExpectedOrigin(), + expectedRPID: getRpId(), + credential: { + id: credential.credentialId, + publicKey: credential.credentialPublicKey, + counter: Number(credential.counter), + transports: credential.transports as Transport[], + }, }); - if (!credential) return false; - const verification: VerifiedAuthenticationResponse = - await verifyAuthenticationResponse({ - response: typedResponse, - expectedChallenge: challenge.challenge, - expectedOrigin: getExpectedOrigin(), - expectedRPID: getRpId(), - credential: { - id: credential.credentialId, - publicKey: credential.credentialPublicKey, - counter: Number(credential.counter), - transports: credential.transports as Transport[], - }, - }); - - if (verification.verified) { - await prisma.webauthnMfaCredential.update({ - where: { id: credential.id }, - data: { - counter: BigInt(verification.authenticationInfo.newCounter), - lastUsedAt: new Date(), - }, - }); - } - - return verification.verified; - } finally { - await prisma.authChallenge - .delete({ where: { id: challengeId } }) - .catch(() => {}); + if (verification.verified) { + await prisma.webauthnMfaCredential.update({ + where: { id: credential.id }, + data: { + counter: BigInt(verification.authenticationInfo.newCounter), + lastUsedAt: new Date(), + }, + }); } + + return verification.verified; } diff --git a/src/lib/auth/passkey.ts b/src/lib/auth/passkey.ts index d32ee3ad6..8598beb0d 100644 --- a/src/lib/auth/passkey.ts +++ b/src/lib/auth/passkey.ts @@ -201,6 +201,46 @@ export async function consumeRegistrationChallenge(input: { }; } +/** + * Claim a WebAuthn challenge: delete it and return its material in one + * statement, or return null when it is unknown, expired, of another ceremony, + * or (when `userId` is given) minted for another account. + * + * Every ceremony verifier goes through here. They used to read the challenge, + * verify, and delete it in a `finally`, so two requests carrying the same + * assertion could both read it before either deleted it and both be + * accepted. The guarded DELETE ... RETURNING gives exactly one of them the + * challenge; the other finds nothing. + */ +export async function claimAuthChallenge(input: { + challengeId: string; + type: string; + userId?: string; +}): Promise<{ challenge: string; userId: string | null } | null> { + const rows = input.userId + ? await prisma.$queryRaw< + Array<{ challenge: string; user_id: string | null }> + >` + DELETE FROM auth_challenges + WHERE id = ${input.challengeId} + AND type = ${input.type} + AND user_id = ${input.userId} + AND expires_at > NOW() + RETURNING challenge, user_id + ` + : await prisma.$queryRaw< + Array<{ challenge: string; user_id: string | null }> + >` + DELETE FROM auth_challenges + WHERE id = ${input.challengeId} + AND type = ${input.type} + AND expires_at > NOW() + RETURNING challenge, user_id + `; + if (rows.length !== 1) return null; + return { challenge: rows[0].challenge, userId: rows[0].user_id }; +} + export async function verifyRegistration( expectedChallenge: string, response: unknown, @@ -271,11 +311,13 @@ export async function verifyAuthentication( verification: VerifiedAuthenticationResponse; passkey: { userId: string }; }> { - const challenge = await prisma.authChallenge.findUnique({ - where: { id: challengeId }, + // Claimed before anything else: a challenge is good for one verification, + // successful or not, and a concurrent second request finds nothing. + const challenge = await claimAuthChallenge({ + challengeId, + type: "authentication", }); - - if (!challenge || challenge.expiresAt < new Date()) { + if (!challenge) { throw new Error("Challenge expired or not found"); } @@ -301,37 +343,36 @@ export async function verifyAuthentication( throw new Error("Passkey not found"); } - try { - const verification = await verifyAuthenticationResponse({ - response: typedResponse, - expectedChallenge: challenge.challenge, - expectedOrigin: getExpectedOrigin(), - expectedRPID: getRpId(), - credential: { - id: passkey.credentialId, - publicKey: passkey.credentialPublicKey, - counter: Number(passkey.counter), - transports: passkey.transports as Transport[], + // A challenge begun for one account (the re-proof arm names the account) + // is answered only by that account's passkey. + if (challenge.userId !== null && challenge.userId !== passkey.userId) { + throw new Error("Challenge expired or not found"); + } + + const verification = await verifyAuthenticationResponse({ + response: typedResponse, + expectedChallenge: challenge.challenge, + expectedOrigin: getExpectedOrigin(), + expectedRPID: getRpId(), + credential: { + id: passkey.credentialId, + publicKey: passkey.credentialPublicKey, + counter: Number(passkey.counter), + transports: passkey.transports as Transport[], + }, + }); + + if (verification.verified) { + // Update counter + stamp last-used so the management UI can surface + // when each passkey was last exercised (v1.23 passkey QoL). + await prisma.passkey.update({ + where: { id: passkey.id }, + data: { + counter: BigInt(verification.authenticationInfo.newCounter), + lastUsedAt: new Date(), }, }); - - if (verification.verified) { - // Update counter + stamp last-used so the management UI can surface - // when each passkey was last exercised (v1.23 passkey QoL). - await prisma.passkey.update({ - where: { id: passkey.id }, - data: { - counter: BigInt(verification.authenticationInfo.newCounter), - lastUsedAt: new Date(), - }, - }); - } - - return { verification, passkey: { userId: passkey.userId } }; - } finally { - // Invalidate challenge after first verification attempt (success or failure) - await prisma.authChallenge - .delete({ where: { id: challengeId } }) - .catch(() => {}); } + + return { verification, passkey: { userId: passkey.userId } }; } diff --git a/src/lib/auth/refresh-token.ts b/src/lib/auth/refresh-token.ts index 007c70ed7..1edb8bfc8 100644 --- a/src/lib/auth/refresh-token.ts +++ b/src/lib/auth/refresh-token.ts @@ -193,13 +193,16 @@ export async function rotateRefreshToken(input: { // `row.deviceId ?? input.deviceId` — would sit outside the victim device's // containment blast radius. // - // Both ids must be present to compare. A stored null means the row predates - // per-device issuance and cannot be attributed; a presented null means the - // caller sent no `X-Device-Id` header. Neither is treated as a mismatch, so - // this cannot lock out an older client that never sends the header. + // A stored null means the row predates per-device issuance, or the client + // sent no `X-Device-Id` when it signed in: nothing to compare, so it + // rotates. A stored id must be matched exactly, and an absent header does + // not match it. It used to: leaving the header out skipped the check, so a + // thief holding a stolen token rotated it simply by not naming a device. A + // client that sent its id at sign-in sends it on every refresh, so this + // locks out nobody who signed in honestly. const presentedId = input.deviceId ?? null; const storedId = row.deviceId ?? null; - if (presentedId !== null && storedId !== null && presentedId !== storedId) { + if (storedId !== null && presentedId !== storedId) { return { ok: false, reason: "device_mismatch" }; } diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index f484953d2..2884ae478 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -509,6 +509,20 @@ export async function destroyAllSessions(userId: string): Promise { // trusted-device token: a stolen device cookie must not survive a // password change or account-credential remediation. prisma.trustedDevice.deleteMany({ where: { userId } }), + // A connected AI assistant holds a refresh chain of its own: revoking its + // access tokens above left the connection able to mint the next one. + prisma.mcpOAuthConnection.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }), + // A clinician share link opens the record without signing in. Whoever + // held the old password could have made one, and it would outlive the + // change; the owner makes a fresh link for the people who should keep it. + prisma.clinicianShareLink.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }), + prisma.stepUpElevation.deleteMany({ where: { userId } }), ]); } @@ -526,9 +540,10 @@ export type CurrentCredential = * v1.23 — "sign out everywhere" for the user-facing active-session surface * (issue #64). Distinct from `destroyAllSessions`: this keeps the caller's * CURRENT credential alive so pressing the button does not sign out the device - * it was pressed on, and it leaves the long-lived programmatic `ApiToken`s - * alone — those are credentials the user manages separately under - * /settings/api-tokens, not "sessions" in the device-list sense. + * it was pressed on. Until v1.39.3 it also left the programmatic tokens, the + * AI-assistant connections and the share links alone, as credentials managed + * on their own cards; with `reach: "everything"` it now ends those too (see + * below), because a stolen session could have minted any of them. * * It DOES revoke every other native-client `RefreshToken` (each is a device * login), and, since v1.38.11, the access token paired with each of them. A @@ -542,13 +557,42 @@ export type CurrentCredential = * rotation's own consume step refuses a row revoked underneath it and retires * what it minted. * - * Returns the number of OTHER web sessions removed and the number of access - * tokens revoked, so the surface and the audit row can say what happened. + * `reach` says how far "everywhere" goes: + * + * `sign-ins` — web sessions, device logins, trusted devices and step-up + * elevations. What turning a second factor on or off ends, because those + * change what a sign-in needs, and a sign-in made under the old rule must + * not outlive it. + * + * `everything` — the above, plus every credential the account handed out + * that works without signing in: connected AI assistants (their OAuth + * connection, not only the current access token, since the connection mints + * the next one), every programmatic token (the connector, measurement and + * document tokens), and, unless `keepShareLinks`, every clinician share + * link. What the user-facing "sign out everywhere" does: someone who presses + * it after losing a device or a session expects nothing to remain that + * reaches the record. Share links default to revoked for the same reason, + * and the web offers to keep them because a link already handed to a + * doctor is often wanted. The caller's own credential is spared either way. + * + * Returns what was removed, so the surface and the audit row can say what + * happened. */ +export type SignOutReach = + { reach: "sign-ins" } | { reach: "everything"; keepShareLinks?: boolean }; + +export interface SignOutResult { + sessionsRevoked: number; + accessTokensRevoked: number; + connectorsRevoked: number; + shareLinksRevoked: number; +} + export async function destroyOtherSessions( userId: string, current: CurrentCredential, -): Promise<{ sessionsRevoked: number; accessTokensRevoked: number }> { + options: SignOutReach, +): Promise { // Which rows describe the CALLER depends on how the caller authenticated, and // the two shapes are not interchangeable. A cookie caller is a `Session` row. // A Bearer caller is an `ApiToken` whose device login is the `RefreshToken` @@ -604,9 +648,43 @@ export async function destroyOtherSessions( // expects nothing to remain that could act on the account; a live elevation // is exactly that. await tx.stepUpElevation.deleteMany({ where: { userId } }); + + if (options.reach === "sign-ins") { + return { + sessionsRevoked: deleted.count, + accessTokensRevoked: access.count, + connectorsRevoked: 0, + shareLinksRevoked: 0, + }; + } + + // Everything else that reaches the record without a sign-in. Every + // remaining token goes, except the Bearer caller's own access token. + const tokens = await tx.apiToken.updateMany({ + where: { + userId, + revoked: false, + ...(current.kind === "accessToken" + ? { tokenHash: { not: current.accessTokenHash } } + : {}), + }, + data: { revoked: true }, + }); + const connectors = await tx.mcpOAuthConnection.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt }, + }); + const links = options.keepShareLinks + ? { count: 0 } + : await tx.clinicianShareLink.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt }, + }); return { sessionsRevoked: deleted.count, - accessTokensRevoked: access.count, + accessTokensRevoked: access.count + tokens.count, + connectorsRevoked: connectors.count, + shareLinksRevoked: links.count, }; }); } diff --git a/src/lib/logging/__tests__/redact.test.ts b/src/lib/logging/__tests__/redact.test.ts index ddabcdc7e..57cd1f30c 100644 --- a/src/lib/logging/__tests__/redact.test.ts +++ b/src/lib/logging/__tests__/redact.test.ts @@ -117,6 +117,17 @@ describe("redactSecrets", () => { ).toBe('{"token": "[REDACTED]"}'); }); + it.each([ + ["an invite token", `hlv_${"0a".repeat(32)}`], + ["a session secret", `hls_${"ab".repeat(32)}`], + ["an elevation", `hle_${"cd".repeat(32)}`], + ["a native sign-in handoff code", `hlh_${"Ab9_-".repeat(9)}Ab`], + ["an MCP refresh token", `hlrt_${"x".repeat(40)}`], + ])("redacts %s wherever it appears", (_label, token) => { + expect(redactSecrets(`code=${token} next`)).not.toContain(token); + expect(redactSecrets(`value: ${token}`)).toBe("value: [REDACTED]"); + }); + it("does NOT redact non-token strings starting with `hl`", () => { expect(redactSecrets("html_render finished")).toBe("html_render finished"); expect(redactSecrets("healthcheck ok")).toBe("healthcheck ok"); diff --git a/src/lib/logging/redact.ts b/src/lib/logging/redact.ts index 426417981..51871e5d3 100644 --- a/src/lib/logging/redact.ts +++ b/src/lib/logging/redact.ts @@ -84,18 +84,21 @@ export function redactSecrets(input: string): string { // identifiers. Capture group 1 = the leading separator (preserved). .replace(/(^|[^A-Za-z0-9])sk-(?:ant-)?[A-Za-z0-9_-]{8,}/g, "$1[REDACTED]") // HealthLog hex-bodied credentials: `hlk_` access token, `hlr_` refresh - // token, `hls_` session cookie secret (v1.30.32), `hle_` step-up - // elevation (v1.30.34). Same boundary-aware shape as the `sk-` rule. The + // token, `hls_` session cookie secret (v1.30.32) and clinician share + // token, `hle_` step-up elevation (v1.30.34), `hlv_` registration invite. + // Same boundary-aware shape as the `sk-` rule. The // idempotency replay-cache already rejects bodies containing // these prefixes (CLAUDE.md headless-client-API patterns); this // is the matching egress guard for log/error surfaces. - .replace(/(^|[^A-Za-z0-9])hl[kres]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]") + .replace(/(^|[^A-Za-z0-9])hl[kresv]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]") // v1.22.0 — the MCP OAuth bridge artifacts: the authorization code // (`hlac_`), refresh token (`hlrt_`), and DCR client id (`hlc_`). All // are signed, base64url-bodied, and credential-bearing, so they are // scrubbed at the egress boundary like the native token formats above. + // `hlh_` is the one-time native sign-in handoff code, base64url too; it + // rides a custom-scheme redirect and is exchanged for a token pair. .replace( - /(^|[^A-Za-z0-9])hl(?:ac|rt|c)_[A-Za-z0-9_.-]{16,}/g, + /(^|[^A-Za-z0-9])hl(?:ac|rt|c|h)_[A-Za-z0-9_.-]{16,}/g, "$1[REDACTED]", ) // v1.7.0 — `insurance`/`insuranceNumber`/`kvnr` (German statutory- diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 835c1193e..87b7aabf5 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -62,6 +62,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "auth.refresh.revoked", "auth.reproof.failed", "auth.reproof.required", + "auth.reproof.too_weak", "auth.scope.insufficient", "auth.stepup.required", "auth.token.expired", diff --git a/src/lib/openapi/routes/admin-backups.ts b/src/lib/openapi/routes/admin-backups.ts index c86a51dff..44edd3fdc 100644 --- a/src/lib/openapi/routes/admin-backups.ts +++ b/src/lib/openapi/routes/admin-backups.ts @@ -170,7 +170,7 @@ export const adminBackupPaths: NonNullable = { tags: ["Admin"], summary: "Download one stored backup as JSON", description: - "Decrypts one stored copy and returns the backup document itself, with `Content-Disposition: attachment` and `Cache-Control: no-store`. The response is the raw document, not the standard envelope — it is the artefact an operator keeps. The ciphertext stays in the database. Admin session cookie required; Bearer tokens cannot reach admin endpoints.", + "Decrypts one stored copy and returns the backup document itself, with `Content-Disposition: attachment` and `Cache-Control: no-store`. The response is the raw document, not the standard envelope — it is the artefact an operator keeps. The ciphertext stays in the database. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give.", requestParams: { path: z.object({ id: z.string().meta({ description: "`DataBackup.id`." }), @@ -206,7 +206,7 @@ export const adminBackupPaths: NonNullable = { tags: ["Admin"], summary: "Queue the restore of one stored backup over its owner's record", description: - "Queues a restore that replaces the snapshot owner's data tables from the stored copy, in one transaction, under the ids the file carries, and answers 202 with the job's id. Replacing is not merging: every row the account gained after the snapshot was taken is deleted with the rest of its class. The target is the account the snapshot was taken for, never the admin running it. The request refuses a missing confirmation, an unknown backup and a copy that cannot be decrypted; the file's own checks (schema, declared owner, manifest, documents) run in the job before anything is deleted, and a refusal there ends the job as `failed` with the same reason. One restore per account at a time. Admin session cookie required; Bearer tokens cannot reach admin endpoints.", + "Queues a restore that replaces the snapshot owner's data tables from the stored copy, in one transaction, under the ids the file carries, and answers 202 with the job's id. Replacing is not merging: every row the account gained after the snapshot was taken is deleted with the rest of its class. The target is the account the snapshot was taken for, never the admin running it. The request refuses a missing confirmation, an unknown backup and a copy that cannot be decrypted; the file's own checks (schema, declared owner, manifest, documents) run in the job before anything is deleted, and a refusal there ends the job as `failed` with the same reason. One restore per account at a time. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give.", requestParams: { path: z.object({ id: z.string().meta({ description: "`DataBackup.id`." }), diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 7e3885c78..b781b75a6 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -222,6 +222,22 @@ const signOutEverywhereResponse = z .number() .int() .describe("Number of OTHER sessions removed (the current one is kept)."), + accessTokensRevoked: z + .number() + .int() + .describe( + "Tokens revoked: the access tokens of other device logins plus every programmatic token (connector, measurement, document), never the caller's own.", + ), + connectorsRevoked: z + .number() + .int() + .describe("Connected AI-assistant (MCP OAuth) connections revoked."), + shareLinksRevoked: z + .number() + .int() + .describe( + "Clinician share links revoked; 0 when `keepShareLinks=1` was sent.", + ), }) .meta({ id: "SignOutEverywhereResponse" }); @@ -817,7 +833,7 @@ export const authPaths: NonNullable = { "WHICH factor you re-prove decides WHAT the elevation reaches. `password` reaches the same routes a plain cookie session reaches. `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes — MFA disable, recovery-code regeneration, security-key removal — which is precisely the set of ceremonies for which the web marks a session second-factor-verified. The response carries `satisfiesFreshFactor` so a client can choose the right ceremony up front rather than discovering the refusal after spending a proof. A recovery code is NOT accepted here; an account that has lost its authenticator manages its second factor on the web.\n\n" + "The elevation is bound to the exact token that minted it (another token, including the same account's, cannot redeem it), single-use, and valid for five minutes — the same window the cookie path uses. Present it as `X-Step-Up: hle_…` alongside the normal `Authorization: Bearer` header. It is spent only when the target route is about to act, so a 429, a 422, or a wrong code does not burn it.\n\n" + "Presenting the token alone mints nothing: the body must carry a fresh factor proof. Every failure — wrong password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge, a replayed TOTP step — returns the same 401 with the same prose, and is audited server-side. Rate-limited per source address and per account (5 / 15 min); the per-account budget is shared with the web's credential-enrollment proofs. A `totp` proof counts only from an authenticator whose enrollment was confirmed: a code from a pending secret is refused like a wrong one.\n\n" + - "Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE /api/auth/me/mfa/webauthn/{id}; DELETE /api/settings/account; DELETE /api/settings/data. The last three MFA routes, disable, and both erasure routes require a fresh-factor proof. GET /api/auth/me/mfa needs no elevation at all. Nothing else accepts one — admin endpoints stay cookie-only.\n\n" + + "Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE /api/auth/me/mfa/webauthn/{id}; DELETE /api/settings/account; DELETE /api/settings/data; GET /api/export/full-backup; POST /api/export/encrypted. The last three MFA routes, disable, and both erasure routes require a fresh-factor proof. The two whole-record exports take any elevation on an account without a second factor and a fresh-factor one on an account with one; the full backup requires an elevation on every Bearer call, the encrypted export only on an account with a second factor. GET /api/auth/me/mfa needs no elevation at all. Nothing else accepts one — admin endpoints stay cookie-only.\n\n" + "The two erasure routes joined the set so an account with a second factor can be deleted from a native app: their gate was cookie-only, which left an enrolled user unable to delete their own account from the app at all. They take the elevation the same way as the rest — fresh factor required, spent only when the erasure is about to run.", requestBody: { required: true, @@ -836,6 +852,61 @@ export const authPaths: NonNullable = { }, }, }, + "/api/auth/reproof": { + post: { + tags: ["Auth"], + summary: "Re-prove a credential on a browser session (cookie only)", + description: + "Stamps the calling browser session as recently re-proved, so for the next five minutes it may run the actions that ask for a fresh proof: GET /api/export/full-backup, POST /api/export/encrypted, POST /api/share-links, POST /api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/documents, and the admin backup download, upload and restore, the admin data wipe and the admin password reset. Those answer 401 `auth.reproof.required` with `meta.methods` (the proofs this account can give) when the session signed in or re-proved more than five minutes ago.\n\n" + + 'The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`, `webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second factor; on one without, `password` or `passkey`. A method the account cannot use here is refused with 422 `auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also refreshes the session\'s second-factor stamp, exactly as signing in again would. Assertions begin at POST /api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`.\n\n' + + "Proofs draw on the account's shared re-proof budget (five per fifteen minutes, 429 once spent). A proof that does not verify is 401 `auth.reproof.failed` and is audited. Bearer callers use POST /api/auth/step-up instead.", + requestBody: { + required: true, + content: { "application/json": { schema: stepUpMintSchema } }, + }, + responses: { + "200": { + description: "Proof accepted; the session is stamped.", + content: { + "application/json": { + schema: dataEnvelope( + z + .object({ + method: z.enum(["password", "totp", "webauthn", "passkey"]), + verifiedAt: z.string().datetime(), + }) + .meta({ id: "ReproofResponse" }), + "ReproofEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + get: { + tags: ["Auth"], + summary: "Ask whether this browser session's proof is recent", + description: + "The question the gated routes ask, without acting: 200 `{ recent: true }` when the session signed in or re-proved within five minutes (a second factor on an account that has one), otherwise 401 `auth.reproof.required` with `meta.methods`. Lets a client re-prove before sending a large upload rather than after it was refused.", + responses: { + "200": { + description: "The session's proof is recent.", + content: { + "application/json": { + schema: dataEnvelope( + z + .object({ recent: z.literal(true) }) + .meta({ id: "ReproofStatus" }), + "ReproofStatusEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/auth/me/mfa/totp/setup": { post: { tags: ["Auth"], @@ -1257,7 +1328,15 @@ export const authPaths: NonNullable = { 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).", + "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.", + requestParams: { + query: z.object({ + keepShareLinks: z.enum(["1"]).optional().meta({ + description: + "`1` keeps the clinician share links. Anything else, or nothing, revokes them.", + }), + }), + }, responses: { "200": { description: "Other sessions revoked.", @@ -1609,7 +1688,7 @@ export const authPaths: NonNullable = { "Mints a Bearer scoped to exactly `measurements:write` and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN — the only place it ever exists; it is stored as an HMAC and no path re-reveals it.\n\n" + "**What it can do.** `POST /api/measurements` and `POST /api/measurements/batch`, on its owner's own record. Rows it writes carry `source: EXTERNAL`, resolved from the credential rather than taken from the body, so a reading pushed by a bridge stays distinguishable from one typed in by hand and can be picked out with the source filter. A body naming ANY source is refused 422 rather than relabelled — honouring it would defeat the label, overriding it silently would hand the caller rows it did not ask for. `APPLE_HEALTH` is the one that would do real damage: it is half a dedup key the phone also writes into, it participates in the cross-source merge, and it is what decides the Apple Health card may claim a sync happened. For the same reason a write through this credential does not move the native client's sync checkpoint. The rows stay editable by their owner, unlike a connected provider's — the hardware behind the token is the user's own.\n\n" + "**What it cannot do.** Everything else, including the measurement reads on the same paths, the edit and delete legs, and the export — a scope grants what it names and nothing adjacent. It cannot be pointed at a shared record: a request carrying the account selector is refused 403 before any grant is read, whatever grants its holder actually has. And it cannot mint another token, this endpoint included.\n\n" + - "Minting requires a COOKIE SESSION. No Bearer credential reaches this endpoint at any scope, wildcard included — not because a wildcard lacks the reach, but because of the lifetimes involved: a native access token lives a day and what it could mint here lives a year, so admitting one would let a short-lived compromise leave behind a credential that outlives revoking it. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute, and at most 10 live tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}` like any other — revoking frees a slot against the ceiling.", + "Minting requires a COOKIE SESSION with a fresh proof: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one), otherwise 401 `auth.reproof.required` with `meta.methods`. No Bearer credential reaches this endpoint at any scope, wildcard included — not because a wildcard lacks the reach, but because of the lifetimes involved: a native access token lives a day and what it could mint here lives a year, so admitting one would let a short-lived compromise leave behind a credential that outlives revoking it. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute, and at most 10 live tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}` like any other — revoking frees a slot against the ceiling.", requestBody: { required: true, content: { @@ -1676,7 +1755,7 @@ export const authPaths: NonNullable = { "Mints a Bearer scoped to exactly `documents:write` and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN, once; it is stored as an HMAC and no path re-reveals it.\n\n" + "**What it can do.** `POST /api/documents/inbound` on its owner's own record, and `GET /api/documents/inbound/source`, which answers whether a source key is already held so an importer can skip a download. Nothing else. It is the door another document system pushes through: a Paperless-ngx workflow posting a newly tagged document, or an import script copying an archive. Uploads through it draw on a bucket of their own, keyed on the token (default 120 an hour, operator-tunable via `DOCUMENT_UPLOAD_LIMIT_PER_HOUR`), and get back a receipt (`id`, `duplicate`, and `deleted` when the source key belongs to a document the owner deleted) rather than the stored row.\n\n" + "**What it cannot do.** List, read, download, preview, edit, delete or bulk-act on a document, reach the document-AI routes, act on a shared record, or mint another token. Every other route names no scope and refuses it 403.\n\n" + - "Minting requires a COOKIE SESSION, for the reason the measurement mint gives: a day-lived native access token must not be able to leave behind a year-lived credential. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute, and at most 10 live document tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}`.", + "Minting requires a COOKIE SESSION with a fresh proof, as for the measurement mint, for the reason it gives: a day-lived native access token must not be able to leave behind a year-lived credential. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute, and at most 10 live document tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}`.", requestBody: { required: true, content: { diff --git a/src/lib/openapi/routes/export.ts b/src/lib/openapi/routes/export.ts index dfd157c28..9838e73fc 100644 --- a/src/lib/openapi/routes/export.ts +++ b/src/lib/openapi/routes/export.ts @@ -198,7 +198,7 @@ export const exportPaths: NonNullable = { tags: ["Export"], summary: "Download the restore-compatible full backup", description: - "The disaster-recovery-shaped JSON dump, in its PORTABLE form. It matches the canonical backup payload byte for byte with the weekly worker's file, so a user can hand this to an operator and the admin restore accepts it without conversion. Two things the shape cannot state: this route ALWAYS produces the portable mode — it calls the builder with no options, so there is no parameter that reaches the disaster-recovery mode, which is the worker and admin path alone — and the file is NOT the standard response envelope but the raw payload, because the restore expects the payload and not `{ data: … }`. Encrypted columns arrive decrypted, so the file is as sensitive as the record it came from; the `manifest` key inside it declares what was deliberately left out. `Content-Disposition: attachment` with `healthlog-backup--.json`, `Cache-Control: no-store`. Shares the `export:` bucket (10 per hour) with every other export route. Audits as `user.export.full-backup` with the row counts. Cookie or Bearer auth; the caller is always resolved as themselves, so this download cannot be delegated to a shared record.", + "The disaster-recovery-shaped JSON dump, in its PORTABLE form. It matches the canonical backup payload byte for byte with the weekly worker's file, so a user can hand this to an operator and the admin restore accepts it without conversion. Two things the shape cannot state: this route ALWAYS produces the portable mode — it calls the builder with no options, so there is no parameter that reaches the disaster-recovery mode, which is the worker and admin path alone — and the file is NOT the standard response envelope but the raw payload, because the restore expects the payload and not `{ data: … }`. Encrypted columns arrive decrypted, so the file is as sensitive as the record it came from; the `manifest` key inside it declares what was deliberately left out. `Content-Disposition: attachment` with `healthlog-backup--.json`, `Cache-Control: no-store`. Shares the `export:` bucket (10 per hour) with every other export route. Audits as `user.export.full-backup` with the row counts. Cookie or Bearer auth; the caller is always resolved as themselves, so this download cannot be delegated to a shared record. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. A Bearer caller presents an `X-Step-Up` elevation from `POST /api/auth/step-up` (a second-factor one on an account with a second factor) and receives 401 `auth.stepup.required` without it.", responses: { "200": { description: @@ -292,7 +292,7 @@ export const exportPaths: NonNullable = { tags: ["Export"], summary: "Download the full backup as a passphrase-encrypted archive", description: - "v1.23. Same restore-compatible payload as the plaintext full backup, sealed into an `HLX1` archive (Argon2id-derived key + AES-256-GCM) under the supplied `passphrase`. Returns `application/octet-stream` (a `.hlx` file). When the account has a second factor enrolled, the call is step-up gated (`requireFreshMfa`, cookie-only) and returns 401 `auth.stepup.required` without a fresh factor; single-factor accounts use a normal session or Bearer. Shared `export:` rate bucket (10/h). The passphrase is never stored — a forgotten passphrase makes the archive unrecoverable.", + "v1.23. Same restore-compatible payload as the plaintext full backup, sealed into an `HLX1` archive (Argon2id-derived key + AES-256-GCM) under the supplied `passphrase`. Returns `application/octet-stream` (a `.hlx` file). Asks for a fresh proof, like the plaintext full backup. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. On Bearer, an account without a second factor passes on its token, and one with a second factor presents a second-factor `X-Step-Up` elevation (401 `auth.stepup.required` without it). Shared `export:` rate bucket (10/h). The passphrase is never stored — a forgotten passphrase makes the archive unrecoverable.", requestBody: { required: true, content: { diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts index 4e8fdadb7..6ffd297a5 100644 --- a/src/lib/openapi/routes/health-record.ts +++ b/src/lib/openapi/routes/health-record.ts @@ -146,7 +146,7 @@ export const healthRecordPaths: NonNullable = { tags: ["Export"], summary: "Create a clinician share link (v1.11.0)", description: - "Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response, together with a passphrase second factor and a `qrUrl` carrying that passphrase in the URL fragment; a leaked URL without the passphrase cannot open the record. The window and the `selection` are frozen write-once — there is no widen path. `selection` omitted means an empty scope, not a default one, and the `INSURANCE` leaf is refused with a 422 because the share view never decrypts the insurance number. An optional `documentIds` array (bounded ≤50) is validated as the caller's own live documents and frozen onto the link; a documents-only share (no report scope, non-empty `documentIds`) is valid. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). Strict: unknown keys 422.", + "Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response, together with a passphrase second factor and a `qrUrl` carrying that passphrase in the URL fragment; a leaked URL without the passphrase cannot open the record. The window and the `selection` are frozen write-once — there is no widen path. `selection` omitted means an empty scope, not a default one, and the `INSURANCE` leaf is refused with a 422 because the share view never decrypts the insurance number. An optional `documentIds` array (bounded ≤50) is validated as the caller's own live documents and frozen onto the link; a documents-only share (no report scope, non-empty `documentIds`) is valid. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). Strict: unknown keys 422. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. A Bearer caller is not asked for more than its token yet.", requestBody: { required: true, content: { diff --git a/src/lib/openapi/routes/mcp.ts b/src/lib/openapi/routes/mcp.ts index c1f8b22e7..ca585bd6e 100644 --- a/src/lib/openapi/routes/mcp.ts +++ b/src/lib/openapi/routes/mcp.ts @@ -196,7 +196,7 @@ export const mcpPaths: NonNullable = { tags: ["MCP"], summary: "Mint an MCP connector token", description: - "Mints a `health:read` (optionally read+write) Bearer for the manual / stdio path and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN — this is the only place it ever exists, and it cannot be retrieved again. Body capped at 16 KiB. Auth via cookie or Bearer.", + "Mints a `health:read` (optionally read+write) Bearer for the manual / stdio path and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN — this is the only place it ever exists, and it cannot be retrieved again. Body capped at 16 KiB. Auth via cookie or Bearer. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. A Bearer caller is not asked for more than its token yet.", requestBody: { required: true, content: { "application/json": { schema: createMcpTokenRequest } }, diff --git a/src/lib/secret-shape.ts b/src/lib/secret-shape.ts index 7786f7b59..482378132 100644 --- a/src/lib/secret-shape.ts +++ b/src/lib/secret-shape.ts @@ -4,7 +4,10 @@ * such text. * * `hlk_` access tokens, `hlr_` refresh tokens, `hls_` clinician share-link - * tokens, `hlv_` registration invite tokens, `hle_` elevation tokens; + * tokens and session secrets, `hlv_` registration invite tokens, `hle_` + * elevation tokens, `hlh_` native sign-in handoff codes, and the MCP OAuth + * artifacts `hlac_` (authorization code), `hlrt_` (refresh token) and `hlc_` + * (client id); * `sk-…` / `sk-ant-…` OpenAI and Anthropic keys, in the full token form * only: a bare `sk-` substring ("task-id", "risk-management") is not a key, * and matching it would refuse benign text. @@ -14,7 +17,7 @@ * that knows the secrets in play must also refuse text containing them. */ const SECRET_SHAPED = - /(?:\b(?:hlk_|hlr_|hls_|hlv_|hle_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/; + /(?:\b(?:hlk_|hlr_|hls_|hlv_|hle_|hlh_|hlac_|hlrt_|hlc_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/; export function looksSecretShaped(text: string): boolean { return SECRET_SHAPED.test(text); diff --git a/tests/integration/admin-data-wipe.test.ts b/tests/integration/admin-data-wipe.test.ts index 1aaeda45f..0855d8bb7 100644 --- a/tests/integration/admin-data-wipe.test.ts +++ b/tests/integration/admin-data-wipe.test.ts @@ -159,10 +159,13 @@ describe("DELETE /api/admin/data leaves nothing it promised to delete", () => { ).toBeGreaterThan(100); // Created after the generic seed so the cookie carries this session's id. + // The seed gives the admin a second factor, and the wipe asks for a fresh + // proof, so the session is one that has just completed it. const session = await prisma.session.create({ data: { userId: admin.id, expiresAt: new Date(Date.now() + 60_000), + mfaVerifiedAt: new Date(), }, }); cookieJar.set("healthlog_session", session.id); diff --git a/tests/integration/bearer-scope-enforcement.test.ts b/tests/integration/bearer-scope-enforcement.test.ts index d8e082401..5d968d8e5 100644 --- a/tests/integration/bearer-scope-enforcement.test.ts +++ b/tests/integration/bearer-scope-enforcement.test.ts @@ -248,14 +248,36 @@ describe("B2/B3 — the medication-ingest surface is unchanged", () => { }); describe("B4 — the native client is not broken", () => { - it("admits a ['*'] token on GET /api/export/full-backup", async () => { + it("admits a ['*'] token on POST /api/export/encrypted", async () => { // Login, passkey login-verify and refresh rotation all mint `["*"]`, so - // this is exactly the credential the iOS app holds. If this case ever - // goes red, the native client is down. + // this is exactly the credential the iOS app holds, on the whole-record + // export the app calls. If this case ever goes red, the app's backup is + // down. + await armToken(["*"], "wildcard"); + const { POST } = await import("@/app/api/export/encrypted/route"); + const res = await POST( + new NextRequest("https://health.example/api/export/encrypted", { + method: "POST", + headers: { + authorization: headerJar.get("authorization")!, + "content-type": "application/json", + }, + body: JSON.stringify({ passphrase: "a long enough passphrase" }), + }), + ); + expect(res.status).toBe(200); + await res.arrayBuffer(); + }); + + it("passes a ['*'] token through the scope check on GET /api/export/full-backup, to the elevation it needs", async () => { + // The plaintext full backup asks a Bearer caller for a step-up + // elevation. No shipped client calls it on Bearer. What this pins is that + // the refusal is the proof gate's 401, not the scope layer's 403. await armToken(["*"], "wildcard"); const { GET } = await import("@/app/api/export/full-backup/route"); const res = await GET(req("/api/export/full-backup")); - expect(res.status).toBe(200); + expect(res.status).toBe(401); + expect((await res.json()).meta.errorCode).toBe("auth.stepup.required"); }); it("admits a ['*'] token on a batch ingest route", async () => { diff --git a/tests/integration/export-per-type.test.ts b/tests/integration/export-per-type.test.ts index 567a11d9f..6b7b2ed08 100644 --- a/tests/integration/export-per-type.test.ts +++ b/tests/integration/export-per-type.test.ts @@ -136,6 +136,12 @@ describe("GET /api/export/measurements", () => { const audits = await prisma.auditLog.findMany({ where: { userId: me.id, action: "user.export.measurements" }, }); + console.log( + "DBG", + JSON.stringify( + await prisma.auditLog.findMany({ where: { userId: me.id } }), + ), + ); expect(audits.length).toBe(1); }); }); @@ -258,6 +264,12 @@ describe("GET /api/export/mood", () => { const audits = await prisma.auditLog.findMany({ where: { userId: me.id, action: "user.export.mood" }, }); + console.log( + "DBG", + JSON.stringify( + await prisma.auditLog.findMany({ where: { userId: me.id } }), + ), + ); expect(audits.length).toBe(1); }); }); @@ -317,9 +329,13 @@ describe("GET /api/export/full-backup", () => { expect(parsed.measurements.length).toBe(1); expect(parsed.moodEntries.length).toBe(1); - const audits = await prisma.auditLog.findMany({ - where: { userId: me.id, action: "user.export.full-backup" }, + // The audit row is written once the last byte has gone out, which is + // after the body resolves here; wait for it rather than race it. + await vi.waitFor(async () => { + const audits = await prisma.auditLog.findMany({ + where: { userId: me.id, action: "user.export.full-backup" }, + }); + expect(audits.length).toBe(1); }); - expect(audits.length).toBe(1); }); }); diff --git a/tests/integration/recent-proof.test.ts b/tests/integration/recent-proof.test.ts new file mode 100644 index 000000000..1a1325823 --- /dev/null +++ b/tests/integration/recent-proof.test.ts @@ -0,0 +1,212 @@ +/** + * The fresh-proof gate in front of whole-record exports, share links, token + * minting and the admin data actions, against real Postgres. + * + * A live browser session is not enough for these: it needs a sign-in or a + * re-proof inside five minutes (a second factor, on an account that has one). + * `POST /api/auth/reproof` is how a session re-proves in place. + */ +import { NextRequest } from "next/server"; +import * as OTPAuth from "otpauth"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-recent-proof-32-bytes-minimum-1234567890ab"; + +const { hashPassword } = await import("@/lib/auth/password"); + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const USER_ID = "user-recent-proof"; +const PASSWORD = "Correct horse battery staple!42"; +const HOUR = 60 * 60 * 1000; + +type RouteFn = (request: NextRequest) => Promise; + +function request(method: string, path: string, body?: unknown): NextRequest { + return new NextRequest(`http://localhost${path}`, { + method, + headers: body === undefined ? {} : { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +async function fullBackup() { + const { GET } = await import("@/app/api/export/full-backup/route"); + return (GET as unknown as RouteFn)(request("GET", "/api/export/full-backup")); +} + +async function reproof(body: unknown) { + const { POST } = await import("@/app/api/auth/reproof/route"); + return (POST as unknown as RouteFn)( + request("POST", "/api/auth/reproof", body), + ); +} + +async function startSession(createdAgoMs: number): Promise { + const now = Date.now(); + const session = await getPrismaClient().session.create({ + data: { + userId: USER_ID, + createdAt: new Date(now - createdAgoMs), + expiresAt: new Date(now + HOUR), + }, + }); + cookieJar.set("healthlog_session", session.id); + return session.id; +} + +async function body(res: Response) { + return (await res.json()) as { + meta?: { errorCode?: string; methods?: string[] }; + }; +} + +async function enrollTotp(): Promise { + const { generateTotpSecret } = await import("@/lib/auth/mfa/totp"); + const { encrypt } = await import("@/lib/crypto"); + const secret = generateTotpSecret(); + await getPrismaClient().user.update({ + where: { id: USER_ID }, + data: { + totpSecretEncrypted: encrypt(secret), + totpConfirmedAt: new Date(), + totpLastStep: null, + }, + }); + return secret; +} + +function codeFor(secret: string): string { + return new OTPAuth.TOTP({ + issuer: "HealthLog", + label: "HealthLog", + algorithm: "SHA1", + digits: 6, + period: 30, + secret: OTPAuth.Secret.fromBase32(secret), + }).generate({ timestamp: Date.now() }); +} + +beforeEach(async () => { + vi.clearAllMocks(); + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: "recent-proof-user", + email: "recent-proof@example.test", + role: "USER", + passwordHash: await hashPassword(PASSWORD), + }, + }); +}); + +describe("account without a second factor", () => { + it("exports right after signing in", async () => { + await startSession(30_000); + const res = await fullBackup(); + expect(res.status).toBe(200); + await res.text(); + }); + + it("refuses an old session and names the password as the way through", async () => { + await startSession(HOUR); + const res = await fullBackup(); + expect(res.status).toBe(401); + const b = await body(res); + expect(b.meta?.errorCode).toBe("auth.reproof.required"); + expect(b.meta?.methods).toEqual(["password"]); + }); + + it("exports after a password re-proof, which stamps only reproofAt", async () => { + const sessionId = await startSession(HOUR); + const proved = await reproof({ method: "password", password: PASSWORD }); + expect(proved.status).toBe(200); + const row = await getPrismaClient().session.findUniqueOrThrow({ + where: { id: sessionId }, + }); + expect(row.reproofAt).not.toBeNull(); + expect(row.mfaVerifiedAt).toBeNull(); + + const res = await fullBackup(); + expect(res.status).toBe(200); + await res.text(); + }); + + it("refuses and audits a wrong password without stamping", async () => { + const sessionId = await startSession(HOUR); + const res = await reproof({ method: "password", password: "nope" }); + expect(res.status).toBe(401); + expect((await body(res)).meta?.errorCode).toBe("auth.reproof.failed"); + const row = await getPrismaClient().session.findUniqueOrThrow({ + where: { id: sessionId }, + }); + expect(row.reproofAt).toBeNull(); + expect( + await getPrismaClient().auditLog.count({ + where: { userId: USER_ID, action: "auth.mfa.failed" }, + }), + ).toBe(1); + }); +}); + +describe("account with a second factor", () => { + it("refuses the password as a proof before spending a guess", async () => { + await enrollTotp(); + await startSession(HOUR); + const res = await reproof({ method: "password", password: PASSWORD }); + expect(res.status).toBe(422); + const b = await body(res); + expect(b.meta?.errorCode).toBe("auth.reproof.too_weak"); + expect(b.meta?.methods).toEqual(["totp"]); + expect( + await getPrismaClient().rateLimit.count({ + where: { key: `auth:step-up:${USER_ID}` }, + }), + ).toBe(0); + }); + + it("a fresh sign-in alone is not enough; a TOTP re-proof is", async () => { + const secret = await enrollTotp(); + const sessionId = await startSession(30_000); + const refused = await fullBackup(); + expect(refused.status).toBe(401); + expect((await body(refused)).meta?.methods).toEqual(["totp"]); + + const proved = await reproof({ method: "totp", code: codeFor(secret) }); + expect(proved.status).toBe(200); + const row = await getPrismaClient().session.findUniqueOrThrow({ + where: { id: sessionId }, + }); + expect(row.mfaVerifiedAt).not.toBeNull(); + + const res = await fullBackup(); + expect(res.status).toBe(200); + await res.text(); + }); +}); diff --git a/tests/integration/refresh-rotation-revoke-race.test.ts b/tests/integration/refresh-rotation-revoke-race.test.ts index c79bb0892..50c767199 100644 --- a/tests/integration/refresh-rotation-revoke-race.test.ts +++ b/tests/integration/refresh-rotation-revoke-race.test.ts @@ -81,10 +81,11 @@ describe("refresh rotation — revocation landing between read and consume", () .mockImplementation((async (args: unknown) => { if (!revokedInsideTheWindow) { revokedInsideTheWindow = true; - await destroyOtherSessions(user.id, { - kind: "session", - sessionId: browser.id, - }); + await destroyOtherSessions( + user.id, + { kind: "session", sessionId: browser.id }, + { reach: "everything" }, + ); } return original(args as never); }) as never); diff --git a/tests/integration/second-factor-ends-other-sessions.test.ts b/tests/integration/second-factor-ends-other-sessions.test.ts new file mode 100644 index 000000000..d0afaeab0 --- /dev/null +++ b/tests/integration/second-factor-ends-other-sessions.test.ts @@ -0,0 +1,138 @@ +/** + * 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 + * the owner is locking out, must not keep going without it. Adding a further + * factor to an account that already has one leaves other sign-ins alone. + */ +import { NextRequest } from "next/server"; +import * as OTPAuth from "otpauth"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-second-factor-32-bytes-minimum-1234567890"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const USER_ID = "user-second-factor"; +const HOUR = 60 * 60 * 1000; + +type RouteFn = (request: NextRequest) => Promise; + +async function confirm(code: string) { + const { POST } = await import("@/app/api/auth/me/mfa/totp/confirm/route"); + return (POST as unknown as RouteFn)( + new NextRequest("http://localhost/api/auth/me/mfa/totp/confirm", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ code }), + }), + ); +} + +async function session(): Promise { + const row = await getPrismaClient().session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + HOUR) }, + }); + return row.id; +} + +async function pendingSecret(): Promise { + const { generateTotpSecret } = await import("@/lib/auth/mfa/totp"); + const { encrypt } = await import("@/lib/crypto"); + const secret = generateTotpSecret(); + await getPrismaClient().user.update({ + where: { id: USER_ID }, + data: { totpSecretEncrypted: encrypt(secret), totpConfirmedAt: null }, + }); + return secret; +} + +function codeFor(secret: string): string { + return new OTPAuth.TOTP({ + issuer: "HealthLog", + label: "HealthLog", + algorithm: "SHA1", + digits: 6, + period: 30, + secret: OTPAuth.Secret.fromBase32(secret), + }).generate({ timestamp: Date.now() }); +} + +beforeEach(async () => { + vi.clearAllMocks(); + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: "second-factor-user", + email: "second-factor@example.test", + role: "USER", + }, + }); +}); + +describe("TOTP confirmation", () => { + it("ends every other sign-in when it is the account's first second factor", async () => { + const secret = await pendingSecret(); + const other = await session(); + const current = await session(); + cookieJar.set("healthlog_session", current); + + const res = await confirm(codeFor(secret)); + expect(res.status).toBe(200); + + const left = await getPrismaClient().session.findMany({ + where: { userId: USER_ID }, + select: { id: true }, + }); + expect(left.map((s) => s.id)).toEqual([current]); + expect(left.map((s) => s.id)).not.toContain(other); + }); + + it("leaves other sign-ins alone when a security key was already there", async () => { + const secret = await pendingSecret(); + await getPrismaClient().webauthnMfaCredential.create({ + data: { + userId: USER_ID, + name: "Key", + credentialId: "cred-1", + credentialPublicKey: Buffer.from("pk"), + counter: BigInt(0), + transports: [], + }, + }); + await session(); + const current = await session(); + cookieJar.set("healthlog_session", current); + + const res = await confirm(codeFor(secret)); + expect(res.status).toBe(200); + expect( + await getPrismaClient().session.count({ where: { userId: USER_ID } }), + ).toBe(2); + }); +}); diff --git a/tests/integration/sessions-and-known-devices.test.ts b/tests/integration/sessions-and-known-devices.test.ts index bd80a95f7..719fbd3e8 100644 --- a/tests/integration/sessions-and-known-devices.test.ts +++ b/tests/integration/sessions-and-known-devices.test.ts @@ -4,7 +4,9 @@ * * Covers the DB-layer contracts the unit mocks can't pin: * - destroyOtherSessions keeps the current session, deletes the rest, revokes - * native refresh tokens, and leaves API tokens untouched. + * native refresh tokens; with `reach: "sign-ins"` it leaves API tokens, + * connections and share links untouched, with `reach: "everything"` it + * ends them too (share links unless kept). * - destroySessionById is scoped to the owning user (no cross-user delete) * and takes the public handle, never the row id. * - the (userId, deviceHash) unique index enforces the login-alert dedupe. @@ -29,7 +31,7 @@ async function makeUser(username: string) { } describe("destroyOtherSessions", () => { - it("keeps the current session, removes the others, revokes refresh tokens, keeps API tokens", async () => { + it("sign-ins: keeps the current session, removes the others, revokes refresh tokens, keeps API tokens", async () => { const prisma = getPrismaClient(); const user = await makeUser("sess-owner"); @@ -58,10 +60,11 @@ describe("destroyOtherSessions", () => { }, }); - const result = await destroyOtherSessions(user.id, { - kind: "session", - sessionId: current.id, - }); + const result = await destroyOtherSessions( + user.id, + { kind: "session", sessionId: current.id }, + { reach: "sign-ins" }, + ); expect(result.sessionsRevoked).toBe(2); const remaining = await prisma.session.findMany({ @@ -204,10 +207,11 @@ describe("destroyOtherSessions — native device logins", () => { }, }); - const result = await destroyOtherSessions(user.id, { - kind: "session", - sessionId: current.id, - }); + const result = await destroyOtherSessions( + user.id, + { kind: "session", sessionId: current.id }, + { reach: "sign-ins" }, + ); expect(result.accessTokensRevoked).toBe(2); const revoked = await prisma.apiToken.findMany({ @@ -251,10 +255,11 @@ describe("destroyOtherSessions — native device logins", () => { data: { userId: user.id, expiresAt: new Date(Date.now() + 1e6) }, }); - const result = await destroyOtherSessions(user.id, { - kind: "accessToken", - accessTokenHash: "access-caller", - }); + const result = await destroyOtherSessions( + user.id, + { kind: "accessToken", accessTokenHash: "access-caller" }, + { reach: "sign-ins" }, + ); // A Bearer caller has no session row, so every browser session goes. expect(result.sessionsRevoked).toBe(1); expect(result.accessTokensRevoked).toBe(1); @@ -273,3 +278,183 @@ describe("destroyOtherSessions — native device logins", () => { expect(otherAccess?.revoked).toBe(true); }); }); + +describe("destroyOtherSessions — everything", () => { + async function seedCredentials(userId: string) { + const prisma = getPrismaClient(); + const token = await prisma.apiToken.create({ + data: { + userId, + name: "ingest", + tokenHash: "narrow-hash", + permissions: ["measurements:write"], + }, + }); + const own = await prisma.apiToken.create({ + data: { + userId, + name: "caller", + tokenHash: "access-caller", + permissions: ["*"], + }, + }); + const connection = await prisma.mcpOAuthConnection.create({ + data: { + userId, + clientId: "client", + clientName: "Assistant", + scope: "health:read", + resource: "https://health.example/mcp", + currentJti: "jti-1", + }, + }); + const link = await prisma.clinicianShareLink.create({ + data: { + userId, + tokenHash: "share-hash", + label: "Clinic", + rangeStart: new Date("2026-01-01"), + sectionsJson: { v: 2, leaves: [] }, + expiresAt: new Date(Date.now() + 1e9), + }, + }); + return { token, own, connection, link }; + } + + it("revokes every token but the caller's, every connection and every share link", async () => { + const prisma = getPrismaClient(); + const user = await makeUser("sess-everything"); + const seeded = await seedCredentials(user.id); + + const result = await destroyOtherSessions( + user.id, + { kind: "accessToken", accessTokenHash: "access-caller" }, + { reach: "everything" }, + ); + + expect(result.connectorsRevoked).toBe(1); + expect(result.shareLinksRevoked).toBe(1); + expect( + ( + await prisma.apiToken.findUniqueOrThrow({ + where: { id: seeded.token.id }, + }) + ).revoked, + ).toBe(true); + expect( + ( + await prisma.apiToken.findUniqueOrThrow({ + where: { id: seeded.own.id }, + }) + ).revoked, + ).toBe(false); + expect( + ( + await prisma.mcpOAuthConnection.findUniqueOrThrow({ + where: { id: seeded.connection.id }, + }) + ).revokedAt, + ).not.toBeNull(); + expect( + ( + await prisma.clinicianShareLink.findUniqueOrThrow({ + where: { id: seeded.link.id }, + }) + ).revokedAt, + ).not.toBeNull(); + }); + + it("keeps the share links when asked to, and still ends the rest", async () => { + const prisma = getPrismaClient(); + const user = await makeUser("sess-keep-links"); + const seeded = await seedCredentials(user.id); + + const result = await destroyOtherSessions( + user.id, + { kind: "accessToken", accessTokenHash: "access-caller" }, + { reach: "everything", keepShareLinks: true }, + ); + + expect(result.shareLinksRevoked).toBe(0); + expect(result.connectorsRevoked).toBe(1); + expect( + ( + await prisma.clinicianShareLink.findUniqueOrThrow({ + where: { id: seeded.link.id }, + }) + ).revokedAt, + ).toBeNull(); + }); + + it("sign-ins leaves connections, tokens and share links alone", async () => { + const prisma = getPrismaClient(); + const user = await makeUser("sess-signins-only"); + const seeded = await seedCredentials(user.id); + + await destroyOtherSessions( + user.id, + { kind: "accessToken", accessTokenHash: "access-caller" }, + { reach: "sign-ins" }, + ); + + expect( + ( + await prisma.apiToken.findUniqueOrThrow({ + where: { id: seeded.token.id }, + }) + ).revoked, + ).toBe(false); + expect( + ( + await prisma.mcpOAuthConnection.findUniqueOrThrow({ + where: { id: seeded.connection.id }, + }) + ).revokedAt, + ).toBeNull(); + }); +}); + +describe("destroyAllSessions", () => { + it("also ends AI-assistant connections and share links", async () => { + const prisma = getPrismaClient(); + const { destroyAllSessions } = await import("@/lib/auth/session"); + const user = await makeUser("sess-all"); + const connection = await prisma.mcpOAuthConnection.create({ + data: { + userId: user.id, + clientId: "client", + clientName: "Assistant", + scope: "health:read", + resource: "https://health.example/mcp", + currentJti: "jti-1", + }, + }); + const link = await prisma.clinicianShareLink.create({ + data: { + userId: user.id, + tokenHash: "share-hash-all", + label: "Clinic", + rangeStart: new Date("2026-01-01"), + sectionsJson: { v: 2, leaves: [] }, + expiresAt: new Date(Date.now() + 1e9), + }, + }); + + await destroyAllSessions(user.id); + + expect( + ( + await prisma.mcpOAuthConnection.findUniqueOrThrow({ + where: { id: connection.id }, + }) + ).revokedAt, + ).not.toBeNull(); + expect( + ( + await prisma.clinicianShareLink.findUniqueOrThrow({ + where: { id: link.id }, + }) + ).revokedAt, + ).not.toBeNull(); + }); +}); diff --git a/tests/integration/step-up-elevation.test.ts b/tests/integration/step-up-elevation.test.ts index e6a82cbd4..7e6294853 100644 --- a/tests/integration/step-up-elevation.test.ts +++ b/tests/integration/step-up-elevation.test.ts @@ -658,10 +658,11 @@ describe("E8 — an elevation does not outlive its anchor", () => { data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, }); const { destroyOtherSessions } = await import("@/lib/auth/session"); - await destroyOtherSessions(USER_ID, { - kind: "session", - sessionId: session.id, - }); + await destroyOtherSessions( + USER_ID, + { kind: "session", sessionId: session.id }, + { reach: "everything" }, + ); expect(await getPrismaClient().stepUpElevation.count()).toBe(0); }); diff --git a/tests/integration/webauthn-challenge-claim.test.ts b/tests/integration/webauthn-challenge-claim.test.ts new file mode 100644 index 000000000..e3a31bb3e --- /dev/null +++ b/tests/integration/webauthn-challenge-claim.test.ts @@ -0,0 +1,98 @@ +/** + * A WebAuthn challenge is good for exactly one verification. + * + * The ceremony verifiers used to read the challenge, verify, and delete it + * afterwards, so two requests carrying the same assertion could both read it + * and both pass. `claimAuthChallenge` deletes and returns it in one guarded + * statement; this pins that against real Postgres. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const { claimAuthChallenge } = await import("@/lib/auth/passkey"); + +const USER_ID = "user-challenge-claim"; + +async function challenge( + type: string, + opts: { userId?: string | null; expiresInMs?: number } = {}, +): Promise { + const row = await getPrismaClient().authChallenge.create({ + data: { + userId: opts.userId === undefined ? USER_ID : opts.userId, + challenge: `material-${type}`, + type, + expiresAt: new Date(Date.now() + (opts.expiresInMs ?? 60_000)), + }, + }); + return row.id; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: "claim-user", + email: "claim@example.test", + }, + }); +}); + +describe("claimAuthChallenge", () => { + it("hands the challenge to exactly one of two concurrent claims", async () => { + const id = await challenge("authentication"); + const results = await Promise.all( + Array.from({ length: 8 }, () => + claimAuthChallenge({ challengeId: id, type: "authentication" }), + ), + ); + expect(results.filter((r) => r !== null)).toHaveLength(1); + expect(await getPrismaClient().authChallenge.count({ where: { id } })).toBe( + 0, + ); + }); + + it("returns the material and the account it was begun for", async () => { + const id = await challenge("mfa_authentication"); + await expect( + claimAuthChallenge({ + challengeId: id, + type: "mfa_authentication", + userId: USER_ID, + }), + ).resolves.toEqual({ + challenge: "material-mfa_authentication", + userId: USER_ID, + }); + }); + + it("refuses another ceremony's challenge and leaves it in place", async () => { + const id = await challenge("mfa_authentication"); + await expect( + claimAuthChallenge({ challengeId: id, type: "authentication" }), + ).resolves.toBeNull(); + expect(await getPrismaClient().authChallenge.count({ where: { id } })).toBe( + 1, + ); + }); + + it("refuses a challenge begun for another account", async () => { + const id = await challenge("mfa_registration"); + await expect( + claimAuthChallenge({ + challengeId: id, + type: "mfa_registration", + userId: "someone-else", + }), + ).resolves.toBeNull(); + }); + + it("refuses an expired challenge", async () => { + const id = await challenge("authentication", { expiresInMs: -1_000 }); + await expect( + claimAuthChallenge({ challengeId: id, type: "authentication" }), + ).resolves.toBeNull(); + }); +});