diff --git a/.env.example b/.env.example index 800b3f353..10bc7095e 100644 --- a/.env.example +++ b/.env.example @@ -202,10 +202,11 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32" # operator — there is no way for the server to detect whether the flag # matches the real deployment topology. When in doubt, leave it off. # Number of trusted reverse-proxy hops in front of the app. The client IP is -# read that many entries from the right of the X-Forwarded-For chain. Leave -# unset for a directly-exposed host; set to 1 behind a single reverse proxy. -# A wrong value makes the app refuse to read XFF and share one rate-limit -# bucket across all anonymous callers. +# read that many entries from the right of the X-Forwarded-For chain. Set 0 +# for a directly-exposed host; 1 (the default when unset) behind a single +# reverse proxy. X-Real-IP is read only at 1. A wrong value makes the app +# refuse the headers, log one warning, and share one rate-limit bucket across +# all anonymous callers. # TRUST_PROXY_HOPS="1" # TRUST_CF_CONNECTING_IP="" diff --git a/.env.production.example b/.env.production.example index 9e9a2a949..af58314bd 100644 --- a/.env.production.example +++ b/.env.production.example @@ -473,9 +473,10 @@ API_TOKEN_HMAC_KEY="" # ONLY when Cloudflare is in front -- a direct-exposed self-host must leave # it unset, or an attacker could forge the header. Off by default. # Number of trusted reverse-proxy hops in front of the app; the client IP is -# read that many entries from the right of the X-Forwarded-For chain. Unset -# for a directly-exposed host, 1 behind a single reverse proxy. A wrong -# value makes the app refuse XFF and collapse anonymous callers into one +# read that many entries from the right of the X-Forwarded-For chain. 0 for a +# directly-exposed host, 1 (the default when unset) behind a single reverse +# proxy; X-Real-IP is read only at 1. A wrong value makes the app refuse the +# headers, log one warning, and collapse anonymous callers into one # rate-limit bucket. # TRUST_PROXY_HOPS="1" # TRUST_CF_CONNECTING_IP="1" diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a82bb99ed..ad42df0c6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -864,7 +864,11 @@ paths: `selection` is REQUIRED and carries the leaf inclusion list — membership is inclusion, absence is exclusion, and there is no server-side default. An unknown leaf id is a 422 that names it; the retired grouped `sections` shape is a 422 too, because accepting it would mean choosing a scope the caller did not express. Auth via cookie or - Bearer; shared `export:` rate bucket (10/h). Strict validation: unknown keys 422." + Bearer; shared `export:` rate bucket (10/h). Strict validation: unknown keys 422. A browser session + needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes + (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with + `meta.methods` naming the proofs the account can give. When a manager exports a record they manage, it is their + own session that is asked. A Bearer caller is not asked for more than its token." requestBody: required: true content: @@ -1489,7 +1493,10 @@ paths: Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport (present when the window holds a vital sign) — flattened into one `searchset` Bundle. Scoped to the owner's SAVED report selection; an account that never saved one gets nothing. Auth: `fhir:read` scope. Offset paging via `_count` - (≤200) / `_offset`." + (≤200) / `_offset`. A browser session needs a fresh proof as well as a live one: a sign-in or `POST + /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it + the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. A token, + including a `fhir:read` one, is not asked for more." parameters: - in: query name: _count @@ -1524,7 +1531,13 @@ paths: notes ride their exported column. Shares the `export:` bucket with the encrypted variant (10 per hour) and audits every attempt as `export.download`. Cookie or Bearer auth; the caller is always resolved as themselves, so this download cannot be delegated to a shared record. Not module-gated: a disabled module's rows - are still exported, because this is the account's own data leaving on request." + are still exported, because this is the account's own data leaving on request. `type=all`, the default, is the + whole record and asks for a fresh proof like the full backup; the single-type exports do not. A browser session + needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes + (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with + `meta.methods` naming the proofs the account can give. A Bearer caller asking for `type=all` presents an + `X-Step-Up` elevation from `POST /api/auth/step-up` (a second-factor one on an account with a second factor) and + receives 401 `auth.stepup.required` without it." parameters: - in: query name: format @@ -1852,6 +1865,15 @@ paths: 200 with `data: null, error: null` and `meta.mfaRequired: true` plus a single-use `meta.mfaTicket` and the `meta.methods` list. The client must POST the ticket + a code to `/api/auth/mfa/verify` to obtain the token bundle. Accounts without MFA are unchanged. + + + Two limits apply. Per source address, five attempts in fifteen minutes. Per account, failed passwords from a + place the account has not signed in from before make the next attempt wait: five failures a day are free, then + each one doubles the wait, from thirty seconds up to at most fifteen minutes. A place the account has signed in + from is not counted and not held back: a remembered browser (trusted-device cookie), a phone that sends the + `X-Device-Id` a login of this account was issued to, or an address a session or device login of this account + came from in the last thirty days. Both answer 429 with `Retry-After`. An identifier that names no account is + limited the same way, so a 429 says nothing about whether it exists. requestBody: required: true content: @@ -1871,7 +1893,9 @@ paths: "403": description: "Password login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`; sign in through SSO instead. The one exception is a browser the SSO callback sent back with a pending link to - confirm an existing account: it may sign in with that one account's password, which links the account." + confirm an existing account: it may sign in with that one account's password, which links the account. A + sign-in to any other account on that browser is refused before its password is checked, so the answer is the + same whether the password was right or wrong." content: application/json: schema: @@ -2172,14 +2196,18 @@ paths: /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused. - Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof (a - password-proved one is enough). On the cookie path the request passes when the session signed in, or completed a - second factor, within the last five minutes; otherwise the body must carry a proof in the shape POST - /api/auth/step-up takes (`password`, a `totp` code from an already confirmed authenticator, or a passkey / - security-key assertion begun at POST /api/auth/passkey/register-options). Without one the answer is 401 - `auth.reproof.required`; a proof that does not verify is 401 `auth.reproof.failed` and is audited. Proofs draw - on the account's shared re-proof budget of five per fifteen minutes (429 once spent), the same budget as the - step-up mint. + Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof: a + password-proved one is enough on an account without a second factor, and on an account that already has one the + elevation must come from that factor or a passkey. On the cookie path, for an account without a second factor, + the request passes when the session signed in, or completed a passkey sign-in, within the last five minutes; + otherwise the body must carry a proof in the shape POST /api/auth/step-up takes (`password`, or a passkey + assertion begun at POST /api/auth/passkey/register-options). For an account that already has a second factor, + only that factor counts: a second factor completed within the last five minutes, or a `totp` code, security-key + or passkey assertion in the body. A password or a young session (for example one that skipped the factor on a + remembered browser) is not enough there. Without one the answer is 401 `auth.reproof.required` with + `meta.methods` naming the proofs the account can give; a proof that does not verify is 401 `auth.reproof.failed` + and is audited. Proofs draw on the account's shared re-proof budget of five per fifteen minutes (429 once + spent), the same budget as the step-up mint. requestBody: required: false content: @@ -2316,14 +2344,18 @@ paths: refused. - Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof (a - password-proved one is enough). On the cookie path the request passes when the session signed in, or completed a - second factor, within the last five minutes; otherwise the body must carry a proof in the shape POST - /api/auth/step-up takes (`password`, a `totp` code from an already confirmed authenticator, or a passkey / - security-key assertion begun at POST /api/auth/passkey/register-options). Without one the answer is 401 - `auth.reproof.required`; a proof that does not verify is 401 `auth.reproof.failed` and is audited. Proofs draw - on the account's shared re-proof budget of five per fifteen minutes (429 once spent), the same budget as the - step-up mint. + Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof: a + password-proved one is enough on an account without a second factor, and on an account that already has one the + elevation must come from that factor or a passkey. On the cookie path, for an account without a second factor, + the request passes when the session signed in, or completed a passkey sign-in, within the last five minutes; + otherwise the body must carry a proof in the shape POST /api/auth/step-up takes (`password`, or a passkey + assertion begun at POST /api/auth/passkey/register-options). For an account that already has a second factor, + only that factor counts: a second factor completed within the last five minutes, or a `totp` code, security-key + or passkey assertion in the body. A password or a young session (for example one that skipped the factor on a + remembered browser) is not enough there. Without one the answer is 401 `auth.reproof.required` with + `meta.methods` naming the proofs the account can give; a proof that does not verify is 401 `auth.reproof.failed` + and is audited. Proofs draw on the account's shared re-proof budget of five per fifteen minutes (429 once + spent), the same budget as the step-up mint. requestBody: required: false content: @@ -2600,7 +2632,10 @@ paths: (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." + them. Record-sharing invitations this account offered that nobody has accepted yet are withdrawn too, because an + invitation sent from a stolen session would otherwise wait for the thief's own account. Accepted grants are not + ended: the response lists them as `grantsKept` so the client can show who can still read the record and offer to + end each one. A Bearer caller keeps its own device login." parameters: - in: query name: keepShareLinks @@ -3455,7 +3490,9 @@ paths: **Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options - issued. + issued. On an account that already has a second factor a password is refused before it is checked, with 422 + `auth.reproof.too_weak` and `meta.methods` naming the proofs that work: a passkey added on the password alone + would sign in without the second factor from then on. Which factor you prove changes the session, and the direction is not the obvious one: a strong proof stamps the @@ -3502,7 +3539,13 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a4 + "422": + description: "`auth.reproof.too_weak`: a password on an account that already has a second factor. `meta.methods` names + the proofs that would be accepted. Refused before the password is checked, so it spends no attempt." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/auth/passkey/register-verify: post: @@ -19367,7 +19410,12 @@ paths: (`requireFreshMfaIfEnrolled`, cookie-only): an enrolled account without a fresh factor gets 401 `auth.stepup.required`, and a Bearer caller gets 403 `sharing.invite.manage_browser_only` before anything else happens, because the gate it would hit resolves through the session cookie and would otherwise answer 'not - authenticated' to a caller it had just authenticated." + authenticated' to a caller it had just authenticated. Every invitation, at any level, also needs a fresh proof: + a browser session must have signed in or used `POST /api/auth/reproof` within the last five minutes (with the + second factor, on an account that has one), or it gets 401 `auth.reproof.required` with `meta.methods`; 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 gets 401 `auth.stepup.required` without it. `DELETE /api/auth/me/sessions` withdraws the + invitations nobody has accepted yet." requestBody: required: true content: @@ -19382,8 +19430,10 @@ paths: schema: $ref: "#/components/schemas/AccountGrantEnvelope" "401": - description: "Offering MANAGE from an MFA-enrolled account without a recent second-factor proof (`meta.errorCode: - auth.stepup.required`). Re-prove the factor and retry; READ and WRITE invitations are never gated." + description: "No recent proof. A browser session that has not signed in or re-proved within five minutes gets + `meta.errorCode: auth.reproof.required` with `meta.methods`; re-prove at POST /api/auth/reproof and retry. A + Bearer caller without a valid `X-Step-Up` elevation gets `auth.stepup.required`, as does offering MANAGE + from an MFA-enrolled browser session without a recent second-factor proof." content: application/json: schema: @@ -22971,8 +23021,12 @@ components: a second factor in the last five minutes needs nothing more, otherwise this field must carry the current password. Without an accepted proof the address is not changed and comes back in `rejectedFields` as `reproof_required` or `reproof_failed` (or, when the address was the only field sent, 401 - `auth.reproof.required` / `auth.reproof.failed`). Attempts draw on the account's shared re-proof budget of - five per fifteen minutes. Never stored." + `auth.reproof.required` / `auth.reproof.failed`). On an account with a second factor the password is not + accepted and not checked: only a second factor or passkey completed in the last five minutes counts (at + sign-in or at POST /api/auth/reproof), and without one the address comes back as `second_factor_required` + (or 401 `profile.update.emailSecondFactorRequired` when it was the only field). A token cannot change the + address of such an account. Attempts draw on the account's shared re-proof budget of five per fifteen + minutes. Never stored." type: string maxLength: 512 description: Partial profile update. Every field is optional and an omitted field is left untouched. An explicit null @@ -31482,29 +31536,29 @@ components: onboarding.disclaimer.invalid, onboarding.restart.invalid, onboarding.restart.rateLimited, onboarding.tour.invalid; personal_records — personal_records.invalid_query; practitioner — practitioner.invalid; profile — profile.update.emailInUse, profile.update.emailRateLimited, - profile.update.invalidBody, profile.update.nothingSaved; record_write — record_write.rate_limited; - report-selection — report-selection.body.invalid_json, report-selection.body.invalid_shape, - report-selection.leaves.unknown; route — route.retired; share-link — share-link.document.decryptFailed, - share-link.document.notFound, share-link.document.rateLimited, share-link.documents.invalid, - share-link.report.notFound, share-link.report.rateLimited, share-link.selection.forbidden_leaf, - share-link.selection.unknown_leaf; sharing — sharing.accept.expired, sharing.accept.not_found, - sharing.accept.not_pending, sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, - sharing.invite.invalid, sharing.invite.invalid_scope, sharing.invite.manage_browser_only, - sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, - sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found, - sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority — - source-priority.body.invalid_json, source-priority.body.invalid_shape; tokens — - tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached; vaccination — - vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope, - vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid, - vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — - workout.batch.invalid, workout.batch.payload_too_large, workout.batch.too_large; (unprefixed) — - about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, - health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at, - json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns, - modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only, - private_origin_not_approved, private_origin_not_grantable, rate_limited_self, too_many_rows, - unreadable_json, upstream_invalid_json, vapid_not_configured" + profile.update.emailSecondFactorRequired, profile.update.invalidBody, profile.update.nothingSaved; + record_write — record_write.rate_limited; report-selection — report-selection.body.invalid_json, + report-selection.body.invalid_shape, report-selection.leaves.unknown; route — route.retired; share-link + — share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited, + share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited, + share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing — + sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked, + sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope, + sharing.invite.manage_browser_only, sharing.invite.refused, sharing.invite.self, sharing.not_permitted, + sharing.renounce.already_ended, sharing.renounce.not_found, sharing.revoke.already_ended, + sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, + sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, + source-priority.body.invalid_shape; tokens — tokens.documents.ceiling_reached, + tokens.measurements.ceiling_reached; vaccination — vaccination.booster-invalid, + vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.encounter-not-found, + vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found, + vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, + workout.batch.too_large; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict, + dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict, + invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, + missing_required_columns, modules_conflict, mood_tag_layout_conflict, not_configured, + notification_prefs_conflict, oidc_only, private_origin_not_approved, private_origin_not_grantable, + rate_limited_self, too_many_rows, unreadable_json, upstream_invalid_json, vapid_not_configured" type: string capability: description: "On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`, @@ -39004,11 +39058,57 @@ components: minimum: -9007199254740991 maximum: 9007199254740991 description: Clinician share links revoked; 0 when `keepShareLinks=1` was sent. + pendingInvitesRevoked: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: "Record-sharing invitations this account offered that nobody had accepted yet, now withdrawn (`revokedBy: + GRANTOR`)." + grantsKept: + type: array + items: + type: object + properties: + id: + type: string + description: The grant id, for DELETE /api/account/grants/{id}. + account: + type: object + properties: + id: + type: string + username: + type: string + displayName: + anyOf: + - type: string + - type: "null" + required: + - id + - username + - displayName + additionalProperties: false + access: + type: string + enum: + - READ + - WRITE + - MANAGE + required: + - id + - account + - access + additionalProperties: false + description: Accepted grants this account gave that are still live. Signing out does not end them, because each is a + person the owner chose and ending one silently could cut off a carer; they are listed so a client can show + who can still read the record and offer to end each with DELETE /api/account/grants/{id}. required: - sessionsRevoked - accessTokensRevoked - connectorsRevoked - shareLinksRevoked + - pendingInvitesRevoked + - grantsKept additionalProperties: false SessionRevokeEnvelope: type: object diff --git a/docs/self-hosting/reverse-proxy.md b/docs/self-hosting/reverse-proxy.md index 6ef02203b..5ce2db75f 100644 --- a/docs/self-hosting/reverse-proxy.md +++ b/docs/self-hosting/reverse-proxy.md @@ -52,11 +52,17 @@ every anonymous caller into one shared rate-limit bucket. Match the 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. +when `TRUST_PROXY_HOPS` is exactly `1` and the request carries no +`X-Forwarded-For`, which covers a single proxy that sets only +`X-Real-IP`. With `0` neither header is believed, because without a proxy +in front both come from the caller. With `2` or more, the hop count +describes an `X-Forwarded-For` chain, so have your proxies send one. + +If requests arrive with a forwarding header the setting does not let +HealthLog read, the log shows one warning naming the header and the +setting. Every anonymous caller then shares one rate-limit bucket, so one +person mistyping a password can hold sign-in back for everyone. The usual +cause is `0` behind a proxy that sends only `X-Real-IP`: set `1`. ## Caddy diff --git a/messages/de.json b/messages/de.json index 3f6b58b75..fad8a7342 100644 --- a/messages/de.json +++ b/messages/de.json @@ -126,7 +126,8 @@ "nothingSaved": "Es wurde nichts gespeichert. Korrigiere das markierte Feld und versuche es erneut.", "invalidBody": "Die Profilaktualisierung konnte nicht gelesen werden.", "emailInUse": "Diese E-Mail-Adresse wird bereits verwendet.", - "emailRateLimited": "Zu viele Änderungen der E-Mail-Adresse. Versuche es später erneut." + "emailRateLimited": "Zu viele Änderungen der E-Mail-Adresse. Versuche es später erneut.", + "emailSecondFactorRequired": "Bestätige mit deinem zweiten Faktor oder einem Passkey, um die E-Mail-Adresse zu ändern." } }, "auth": { @@ -471,6 +472,13 @@ "title": "Zweiten Faktor einrichten", "body": "Dein Administrator verlangt einen zweiten Faktor für dieses Konto. Füge eine Authenticator-App oder einen Sicherheitsschlüssel hinzu, um fortzufahren.", "signOut": "Abmelden" + }, + "confirmAccess": { + "title": "Bestätige, dass du es bist", + "body": "Ein Assistent möchte sich mit deinen HealthLog-Daten verbinden. Bestätige, dass du es bist; danach entscheidest du, ob du es erlaubst.", + "action": "Bestätigen", + "failed": "Das hat nicht geklappt. Bitte versuche es noch einmal.", + "nothingToConfirm": "Hier gibt es nichts zu bestätigen. Starte die Verbindung noch einmal in deinem Assistenten." } }, "dashboard": { @@ -5561,7 +5569,8 @@ "rateLimited": "Zu oft geändert. Warte kurz und versuche es dann erneut.", "other": "Dieser Wert wurde nicht angenommen. Bitte prüfen und erneut versuchen.", "reproofRequired": "Gib dein aktuelles Passwort ein, um die E-Mail-Adresse zu ändern.", - "reproofFailed": "Das Passwort stimmt nicht, die E-Mail-Adresse wurde nicht geändert." + "reproofFailed": "Das Passwort stimmt nicht, die E-Mail-Adresse wurde nicht geändert.", + "secondFactorRequired": "Bestätige mit deinem zweiten Faktor oder einem Passkey, um die E-Mail-Adresse zu ändern." }, "avatar": { "title": "Profilfoto", @@ -6556,7 +6565,11 @@ "revokeSessionConfirmBody": "{device} muss sich neu anmelden. An den dort erfassten Daten ändert sich nichts.", "signOutEverywhereConfirmTitle": "Alle anderen Geräte abmelden?", "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" + "signOutEverywhereShareLinks": "Auch die Freigabelinks für Ärztinnen und Ärzte beenden", + "signOutEverywhereInvites": "Einladungen zu deiner Akte, die noch niemand angenommen hat, werden zurückgezogen. Wer schon angenommen hat, behält den Zugriff; du kannst ihn gleich danach beenden.", + "signOutEverywhereInvitesDone": "{count} nicht angenommene Einladung(en) zurückgezogen.", + "grantsKeptTitle": "Diese Personen sehen deine Akte weiterhin", + "grantsKeptBody": "Das Abmelden beendet keinen Zugriff, den du jemandem gegeben hast. Beende ihn hier, wenn du ihn nicht gegeben hast oder nicht mehr willst." }, "privacy": { "encryption": { diff --git a/messages/en.json b/messages/en.json index 324f73f5e..f03291ef3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -126,7 +126,8 @@ "nothingSaved": "Nothing was saved. Check the highlighted field and try again.", "invalidBody": "The profile update could not be read.", "emailInUse": "This email address is already in use.", - "emailRateLimited": "Too many email-address changes. Try again later." + "emailRateLimited": "Too many email-address changes. Try again later.", + "emailSecondFactorRequired": "Confirm with your second factor or a passkey to change the email address." } }, "auth": { @@ -471,6 +472,13 @@ "title": "Set up a second factor", "body": "Your administrator requires a second factor on this account. Add an authenticator app or a security key to continue.", "signOut": "Sign out" + }, + "confirmAccess": { + "title": "Confirm it is you", + "body": "An assistant is asking to connect to your HealthLog data. Confirm it is you, then you will be taken back to decide whether to allow it.", + "action": "Confirm", + "failed": "That did not work. Please try again.", + "nothingToConfirm": "There is nothing to confirm here. Start the connection again from your assistant." } }, "dashboard": { @@ -5561,7 +5569,8 @@ "rateLimited": "Changed too often. Wait a little and try again.", "other": "This value was not accepted. Check it and try again.", "reproofRequired": "Enter your current password to change the email address.", - "reproofFailed": "That password is not correct, so the email address was not changed." + "reproofFailed": "That password is not correct, so the email address was not changed.", + "secondFactorRequired": "Confirm with your second factor or a passkey to change the email address." }, "avatar": { "title": "Profile photo", @@ -6556,7 +6565,11 @@ "revokeSessionConfirmBody": "{device} will need to sign in again. Nothing recorded on it is affected.", "signOutEverywhereConfirmTitle": "Sign out every other device?", "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" + "signOutEverywhereShareLinks": "Also end the doctor share links", + "signOutEverywhereInvites": "Invitations to your record that nobody has accepted yet are withdrawn. People who already accepted keep their access; you can end it next.", + "signOutEverywhereInvitesDone": "Withdrew {count} invitation(s) nobody had accepted.", + "grantsKeptTitle": "These people can still see your record", + "grantsKeptBody": "Signing out does not end access you gave someone. End it here if you did not give it, or no longer want them to have it." }, "privacy": { "encryption": { diff --git a/messages/es.json b/messages/es.json index e2eecfc72..2cf130ebd 100644 --- a/messages/es.json +++ b/messages/es.json @@ -126,7 +126,8 @@ "nothingSaved": "No se guardó nada. Revisa el campo marcado e inténtalo de nuevo.", "invalidBody": "No se pudo leer la actualización del perfil.", "emailInUse": "Esta dirección de correo ya está en uso.", - "emailRateLimited": "Demasiados cambios de dirección de correo. Inténtalo más tarde." + "emailRateLimited": "Demasiados cambios de dirección de correo. Inténtalo más tarde.", + "emailSecondFactorRequired": "Confirma con tu segundo factor o una clave de acceso para cambiar el correo electrónico." } }, "auth": { @@ -471,6 +472,13 @@ "title": "Configurar un segundo factor", "body": "Tu administrador exige un segundo factor en esta cuenta. Añade una app de autenticación o una llave de seguridad para continuar.", "signOut": "Cerrar sesión" + }, + "confirmAccess": { + "title": "Confirma que eres tú", + "body": "Un asistente quiere conectarse a tus datos de HealthLog. Confirma que eres tú y después decidirás si lo permites.", + "action": "Confirmar", + "failed": "No ha funcionado. Inténtalo de nuevo.", + "nothingToConfirm": "Aquí no hay nada que confirmar. Vuelve a iniciar la conexión desde tu asistente." } }, "dashboard": { @@ -5561,7 +5569,8 @@ "rateLimited": "Se ha cambiado demasiadas veces. Espera un poco e inténtalo de nuevo.", "other": "No se aceptó este valor. Revísalo e inténtalo de nuevo.", "reproofRequired": "Introduce tu contraseña actual para cambiar la dirección de correo.", - "reproofFailed": "La contraseña no es correcta, así que la dirección de correo no se ha cambiado." + "reproofFailed": "La contraseña no es correcta, así que la dirección de correo no se ha cambiado.", + "secondFactorRequired": "Confirma con tu segundo factor o una clave de acceso para cambiar el correo electrónico." }, "avatar": { "title": "Foto de perfil", @@ -6556,7 +6565,11 @@ "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 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" + "signOutEverywhereShareLinks": "Terminar también los enlaces compartidos con profesionales médicos", + "signOutEverywhereInvites": "Se retiran las invitaciones a tu historial que nadie ha aceptado todavía. Quien ya aceptó conserva el acceso; puedes quitárselo a continuación.", + "signOutEverywhereInvitesDone": "Se retiraron {count} invitación(es) que nadie había aceptado.", + "grantsKeptTitle": "Estas personas todavía pueden ver tu historial", + "grantsKeptBody": "Cerrar sesión no quita el acceso que diste a alguien. Quítalo aquí si no lo diste o si ya no quieres que lo tenga." }, "privacy": { "encryption": { diff --git a/messages/fr.json b/messages/fr.json index c1a90a9fe..dafcf4538 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -126,7 +126,8 @@ "nothingSaved": "Rien n'a été enregistré. Corrigez le champ signalé et réessayez.", "invalidBody": "La mise à jour du profil n'a pas pu être lue.", "emailInUse": "Cette adresse e-mail est déjà utilisée.", - "emailRateLimited": "Trop de changements d'adresse e-mail. Réessayez plus tard." + "emailRateLimited": "Trop de changements d'adresse e-mail. Réessayez plus tard.", + "emailSecondFactorRequired": "Confirmez avec votre second facteur ou une clé d'accès pour changer l'adresse e-mail." } }, "auth": { @@ -471,6 +472,13 @@ "title": "Configurer un second facteur", "body": "Votre administrateur exige un second facteur sur ce compte. Ajoutez une application d'authentification ou une clé de sécurité pour continuer.", "signOut": "Se déconnecter" + }, + "confirmAccess": { + "title": "Confirmez que c'est bien vous", + "body": "Un assistant demande à se connecter à vos données HealthLog. Confirmez que c'est bien vous, puis vous déciderez si vous l'autorisez.", + "action": "Confirmer", + "failed": "Cela n'a pas fonctionné. Veuillez réessayer.", + "nothingToConfirm": "Il n'y a rien à confirmer ici. Relancez la connexion depuis votre assistant." } }, "dashboard": { @@ -5561,7 +5569,8 @@ "rateLimited": "Modifié trop souvent. Patientez un peu et réessayez.", "other": "Cette valeur n'a pas été acceptée. Vérifiez-la et réessayez.", "reproofRequired": "Saisissez votre mot de passe actuel pour changer l'adresse e-mail.", - "reproofFailed": "Ce mot de passe est incorrect, l'adresse e-mail n'a donc pas été modifiée." + "reproofFailed": "Ce mot de passe est incorrect, l'adresse e-mail n'a donc pas été modifiée.", + "secondFactorRequired": "Confirmez avec votre second facteur ou une clé d'accès pour changer l'adresse e-mail." }, "avatar": { "title": "Photo de profil", @@ -6556,7 +6565,11 @@ "revokeSessionConfirmBody": "{device} devra se reconnecter. Ce qui y est enregistré n'est pas affecté.", "signOutEverywhereConfirmTitle": "Déconnecter tous les autres appareils ?", "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" + "signOutEverywhereShareLinks": "Mettre fin aussi aux liens de partage avec les médecins", + "signOutEverywhereInvites": "Les invitations à votre dossier que personne n'a encore acceptées sont retirées. Les personnes qui ont déjà accepté gardent leur accès ; vous pourrez y mettre fin ensuite.", + "signOutEverywhereInvitesDone": "{count} invitation(s) non acceptée(s) retirée(s).", + "grantsKeptTitle": "Ces personnes peuvent toujours voir votre dossier", + "grantsKeptBody": "Se déconnecter ne met pas fin à l'accès que vous avez donné à quelqu'un. Mettez-y fin ici si vous ne l'avez pas donné ou si vous ne le souhaitez plus." }, "privacy": { "encryption": { diff --git a/messages/it.json b/messages/it.json index 6ec3c1592..8dbe44376 100644 --- a/messages/it.json +++ b/messages/it.json @@ -126,7 +126,8 @@ "nothingSaved": "Non è stato salvato nulla. Controlla il campo evidenziato e riprova.", "invalidBody": "Impossibile leggere l'aggiornamento del profilo.", "emailInUse": "Questo indirizzo email è già in uso.", - "emailRateLimited": "Troppi cambi di indirizzo email. Riprova più tardi." + "emailRateLimited": "Troppi cambi di indirizzo email. Riprova più tardi.", + "emailSecondFactorRequired": "Conferma con il tuo secondo fattore o una passkey per cambiare l'indirizzo email." } }, "auth": { @@ -471,6 +472,13 @@ "title": "Configura un secondo fattore", "body": "Il tuo amministratore richiede un secondo fattore su questo account. Aggiungi un'app di autenticazione o una chiave di sicurezza per continuare.", "signOut": "Esci" + }, + "confirmAccess": { + "title": "Conferma che sei tu", + "body": "Un assistente chiede di collegarsi ai tuoi dati HealthLog. Conferma che sei tu, poi deciderai se consentirlo.", + "action": "Conferma", + "failed": "Non ha funzionato. Riprova.", + "nothingToConfirm": "Qui non c'è nulla da confermare. Avvia di nuovo la connessione dal tuo assistente." } }, "dashboard": { @@ -5561,7 +5569,8 @@ "rateLimited": "Modificato troppe volte. Aspetta un momento e riprova.", "other": "Questo valore non è stato accettato. Controllalo e riprova.", "reproofRequired": "Inserisci la password attuale per cambiare l'indirizzo email.", - "reproofFailed": "La password non è corretta, quindi l'indirizzo email non è stato cambiato." + "reproofFailed": "La password non è corretta, quindi l'indirizzo email non è stato cambiato.", + "secondFactorRequired": "Conferma con il tuo secondo fattore o una passkey per cambiare l'indirizzo email." }, "avatar": { "title": "Foto del profilo", @@ -6556,7 +6565,11 @@ "revokeSessionConfirmBody": "{device} dovrà accedere di nuovo. Quanto registrato su di esso non viene toccato.", "signOutEverywhereConfirmTitle": "Disconnettere tutti gli altri dispositivi?", "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" + "signOutEverywhereShareLinks": "Termina anche i link di condivisione con i medici", + "signOutEverywhereInvites": "Gli inviti alla tua cartella che nessuno ha ancora accettato vengono ritirati. Chi ha già accettato mantiene l'accesso; puoi revocarlo subito dopo.", + "signOutEverywhereInvitesDone": "Ritirati {count} inviti non accettati.", + "grantsKeptTitle": "Queste persone possono ancora vedere la tua cartella", + "grantsKeptBody": "Disconnettersi non revoca l'accesso che hai dato a qualcuno. Revocalo qui se non l'hai dato tu o se non vuoi più che lo abbia." }, "privacy": { "encryption": { diff --git a/messages/ko.json b/messages/ko.json index afd335255..a0f83be9a 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -126,7 +126,8 @@ "nothingSaved": "저장된 내용이 없어요. 표시된 항목을 확인하고 다시 시도해 주세요.", "invalidBody": "프로필 변경 내용을 읽지 못했어요.", "emailInUse": "이미 사용 중인 이메일 주소예요.", - "emailRateLimited": "이메일 주소를 너무 자주 변경했어요. 잠시 후 다시 시도해 주세요." + "emailRateLimited": "이메일 주소를 너무 자주 변경했어요. 잠시 후 다시 시도해 주세요.", + "emailSecondFactorRequired": "이메일 주소를 바꾸려면 2단계 인증 수단이나 패스키로 확인해 주세요." } }, "auth": { @@ -471,6 +472,13 @@ "title": "2차 인증 수단 설정", "body": "관리자가 이 계정에 2차 인증 수단을 요구해요. 인증 앱이나 보안 키를 추가하고 계속해 주세요.", "signOut": "로그아웃" + }, + "confirmAccess": { + "title": "본인인지 확인해 주세요", + "body": "어시스턴트가 HealthLog 데이터에 연결하려고 해요. 본인인지 확인하면 허용할지 정하는 화면으로 돌아가요.", + "action": "확인", + "failed": "확인하지 못했어요. 다시 시도해 주세요.", + "nothingToConfirm": "여기에서 확인할 것이 없어요. 어시스턴트에서 연결을 다시 시작해 주세요." } }, "dashboard": { @@ -5561,7 +5569,8 @@ "rateLimited": "너무 자주 바꿨어요. 잠시 후 다시 시도해 주세요.", "other": "이 값은 저장하지 못했어요. 확인한 뒤 다시 시도해 주세요.", "reproofRequired": "이메일 주소를 변경하려면 현재 비밀번호를 입력하세요.", - "reproofFailed": "비밀번호가 올바르지 않아 이메일 주소가 변경되지 않았습니다." + "reproofFailed": "비밀번호가 올바르지 않아 이메일 주소가 변경되지 않았습니다.", + "secondFactorRequired": "이메일 주소를 바꾸려면 2단계 인증 수단이나 패스키로 확인해 주세요." }, "avatar": { "title": "프로필 사진", @@ -6556,7 +6565,11 @@ "revokeSessionConfirmBody": "{device} — 다시 로그인해야 해요. 기기에 기록된 내용은 영향받지 않아요.", "signOutEverywhereConfirmTitle": "다른 기기를 모두 로그아웃할까요?", "signOutEverywhereConfirmBody": "다른 모든 기기, 연결된 모든 AI 어시스턴트와 모든 API 토큰은 다시 로그인하거나 다시 설정해야 합니다. 이 기기는 로그인 상태로 유지됩니다. 기록한 내용에는 영향이 없습니다.", - "signOutEverywhereShareLinks": "의료진 공유 링크도 종료" + "signOutEverywhereShareLinks": "의료진 공유 링크도 종료", + "signOutEverywhereInvites": "아직 아무도 수락하지 않은 기록 공유 초대는 철회됩니다. 이미 수락한 사람은 접근 권한을 유지하며, 이어서 끝낼 수 있어요.", + "signOutEverywhereInvitesDone": "수락되지 않은 초대 {count}개를 철회했어요.", + "grantsKeptTitle": "이 사람들은 여전히 내 기록을 볼 수 있어요", + "grantsKeptBody": "로그아웃해도 다른 사람에게 준 접근 권한은 끝나지 않아요. 직접 준 적이 없거나 더 이상 원하지 않으면 여기에서 끝내세요." }, "privacy": { "encryption": { diff --git a/messages/pl.json b/messages/pl.json index 4d6ab0504..dedf56606 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -126,7 +126,8 @@ "nothingSaved": "Nic nie zostało zapisane. Sprawdź oznaczone pole i spróbuj ponownie.", "invalidBody": "Nie udało się odczytać aktualizacji profilu.", "emailInUse": "Ten adres e-mail jest już używany.", - "emailRateLimited": "Zbyt wiele zmian adresu e-mail. Spróbuj ponownie później." + "emailRateLimited": "Zbyt wiele zmian adresu e-mail. Spróbuj ponownie później.", + "emailSecondFactorRequired": "Potwierdź drugim składnikiem lub kluczem dostępu, aby zmienić adres e-mail." } }, "auth": { @@ -471,6 +472,13 @@ "title": "Skonfiguruj drugi składnik", "body": "Administrator wymaga drugiego składnika na tym koncie. Dodaj aplikację uwierzytelniającą lub klucz bezpieczeństwa, aby kontynuować.", "signOut": "Wyloguj się" + }, + "confirmAccess": { + "title": "Potwierdź, że to Ty", + "body": "Asystent chce połączyć się z Twoimi danymi w HealthLog. Potwierdź, że to Ty, a potem zdecydujesz, czy na to pozwolić.", + "action": "Potwierdź", + "failed": "Nie udało się. Spróbuj ponownie.", + "nothingToConfirm": "Nie ma tu nic do potwierdzenia. Rozpocznij połączenie ponownie w swoim asystencie." } }, "dashboard": { @@ -5561,7 +5569,8 @@ "rateLimited": "Zmieniane zbyt często. Odczekaj chwilę i spróbuj ponownie.", "other": "Ta wartość nie została przyjęta. Sprawdź ją i spróbuj ponownie.", "reproofRequired": "Wpisz obecne hasło, aby zmienić adres e-mail.", - "reproofFailed": "Hasło jest nieprawidłowe, więc adres e-mail nie został zmieniony." + "reproofFailed": "Hasło jest nieprawidłowe, więc adres e-mail nie został zmieniony.", + "secondFactorRequired": "Potwierdź drugim składnikiem lub kluczem dostępu, aby zmienić adres e-mail." }, "avatar": { "title": "Zdjęcie profilowe", @@ -6556,7 +6565,11 @@ "revokeSessionConfirmBody": "{device} będzie musiało zalogować się ponownie. Zapisane na nim dane pozostają bez zmian.", "signOutEverywhereConfirmTitle": "Wylogować wszystkie pozostałe urządzenia?", "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" + "signOutEverywhereShareLinks": "Zakończ także linki udostępniania dla lekarzy", + "signOutEverywhereInvites": "Zaproszenia do Twojej karty, których nikt jeszcze nie przyjął, zostaną wycofane. Osoby, które już je przyjęły, zachowują dostęp; możesz go zakończyć zaraz potem.", + "signOutEverywhereInvitesDone": "Wycofano nieprzyjęte zaproszenia: {count}.", + "grantsKeptTitle": "Te osoby nadal widzą Twoją kartę", + "grantsKeptBody": "Wylogowanie nie kończy dostępu, który komuś dałeś. Zakończ go tutaj, jeśli go nie dawałeś albo już nie chcesz, żeby go miał." }, "privacy": { "encryption": { diff --git a/src/__tests__/api/auth/login/mfa-branch.test.ts b/src/__tests__/api/auth/login/mfa-branch.test.ts index 3210a5c44..1b9a644e1 100644 --- a/src/__tests__/api/auth/login/mfa-branch.test.ts +++ b/src/__tests__/api/auth/login/mfa-branch.test.ts @@ -9,6 +9,11 @@ vi.mock("@/lib/db", () => ({ prisma: { user: { findFirst: vi.fn() }, webauthnMfaCredential: { count: vi.fn().mockResolvedValue(0) }, + // The per-account throttle's reads: no known place, no wait running. + trustedDevice: { findUnique: vi.fn(async () => null) }, + refreshToken: { findFirst: vi.fn(async () => null) }, + session: { findFirst: vi.fn(async () => null) }, + rateLimit: { findUnique: vi.fn(async () => null), upsert: vi.fn() }, }, })); vi.mock("@/lib/auth/password", () => ({ @@ -165,27 +170,26 @@ describe("login MFA branch", () => { }); }); -describe("per-account login ceiling", () => { +describe("per-account login throttle", () => { 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, - }); + // A wait is running for this account, set by earlier failures. + vi.mocked(prisma.rateLimit.findUnique).mockResolvedValueOnce({ + resetAt: new Date(Date.now() + 60_000), + } as never); 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", - ); + expect(vi.mocked(prisma.rateLimit.findUnique).mock.calls[0][0]).toEqual({ + where: { key: "auth:login:account:u:user-1:wait" }, + select: { resetAt: true }, + }); }); it("keys an unknown identifier on its hash, so a lock says nothing about existence", async () => { diff --git a/src/__tests__/proxy-early-exit-headers.test.ts b/src/__tests__/proxy-early-exit-headers.test.ts index 9095f4105..bfa02f8c5 100644 --- a/src/__tests__/proxy-early-exit-headers.test.ts +++ b/src/__tests__/proxy-early-exit-headers.test.ts @@ -23,6 +23,7 @@ vi.mock("@/lib/process-type", () => ({ })); import { proxy } from "../proxy"; +import { applyBypassRouteHeaders } from "@/lib/http/proxy-bypass-routes"; function makeRequest( pathname: string, @@ -60,6 +61,10 @@ function expectBaselineHeaders(res: Response) { ); expect(res.headers.get("Permissions-Policy")).toContain("camera=()"); expect(res.headers.get("Cross-Origin-Opener-Policy")).toBe("same-origin"); + expect(res.headers.get("Cross-Origin-Resource-Policy")).toBe("same-origin"); + expect(res.headers.get("Cross-Origin-Embedder-Policy")).toBe( + "credentialless", + ); expect(res.headers.get("X-Permitted-Cross-Domain-Policies")).toBe("none"); expect(res.headers.get("Strict-Transport-Security")).toContain( "max-age=31536000", @@ -117,3 +122,22 @@ describe("proxy early exits carry the baseline security headers", () => { expect(res.headers.get("X-Frame-Options")).toBe("DENY"); }); }); + +describe("the routes that bypass the proxy", () => { + it("carry the same cross-origin headers the proxy sets on a full response", () => { + const viaProxy = proxy(makeRequest("/api/health")); + const headers = new Headers(); + applyBypassRouteHeaders(headers); + for (const name of [ + "Cross-Origin-Opener-Policy", + "Cross-Origin-Resource-Policy", + "Cross-Origin-Embedder-Policy", + "X-Content-Type-Options", + "X-Frame-Options", + "X-Permitted-Cross-Domain-Policies", + "Strict-Transport-Security", + ]) { + expect(headers.get(name), name).toBe(viaProxy.headers.get(name)); + } + }); +}); diff --git a/src/__tests__/recent-proof-surface-guard.test.ts b/src/__tests__/recent-proof-surface-guard.test.ts index 26fce210d..bb334ff98 100644 --- a/src/__tests__/recent-proof-surface-guard.test.ts +++ b/src/__tests__/recent-proof-surface-guard.test.ts @@ -73,6 +73,10 @@ function callers(exportName: string): string[] { /** Routes that resolve their own caller and take the recent-proof gate. */ const RECENT_PROOF_ROUTES: Record = { + // Offering access to the record. No shipped client calls it on a token. + "app/api/account/grants/route.ts": "elevation", + // `type=all` only; the single-type exports stay on `requireAuth`. + "app/api/export/route.ts": "elevation", "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 @@ -82,6 +86,24 @@ const RECENT_PROOF_ROUTES: Record = { "app/api/mcp/tokens/route.ts": "token", }; +/** + * Routes that resolve a cookie OR a token through their own resolver + * (`requireAuth` with a scope, `requireRecordAuth`) and add the cookie arm + * only. Their Bearer arm is the token, as with the `token` rule above: the + * shipped app calls both with its token and no elevation. + */ +const TOKEN_ARM_COOKIE_PROOF_ROUTES = [ + "app/api/export/health-record/route.ts", + "app/api/fhir/Patient/$everything/route.ts", +].sort(); + +/** + * The connection consent. It resolves the session through `getSession` + * (OAuth answers in its own error format, not the envelope) and asks for the + * proof on the consent page and again on "allow". + */ +const CONSENT_PROOF_ROUTES = ["app/api/mcp/oauth/authorize/route.ts"]; + /** Cookie-only routes that add the gate after their own resolver. */ const COOKIE_PROOF_ROUTES = [ "app/api/admin/backups/[id]/download/route.ts", @@ -112,10 +134,24 @@ describe("the recent-proof gate", () => { expect(found).toEqual(RECENT_PROOF_ROUTES); }); - it("the cookie arm is added by exactly the known cookie-only routes", () => { + it("the cookie arm is added by exactly the known routes", () => { const found = callers("assertRecentCookieProof"); expect(found.length).toBeGreaterThan(0); - expect(found).toEqual(COOKIE_PROOF_ROUTES); + expect(found).toEqual( + [ + ...COOKIE_PROOF_ROUTES, + ...TOKEN_ARM_COOKIE_PROOF_ROUTES, + ...CONSENT_PROOF_ROUTES, + ].sort(), + ); + }); + + it("a route that resolves either transport asks only on the cookie", () => { + for (const rel of TOKEN_ARM_COOKIE_PROOF_ROUTES) { + expect(read(rel), rel).toMatch( + /if \(auth\.authMethod === "cookie"\) \{\s*await assertRecentCookieProof\(/, + ); + } }); it("each cookie-only route resolves a cookie before it asks", () => { diff --git a/src/__tests__/sharing-recoverable-errors.test.tsx b/src/__tests__/sharing-recoverable-errors.test.tsx index 05e6618cf..67f84b3ff 100644 --- a/src/__tests__/sharing-recoverable-errors.test.tsx +++ b/src/__tests__/sharing-recoverable-errors.test.tsx @@ -345,18 +345,16 @@ describe("a refused invitation keeps what was typed", () => { it("clears the form only where the invitation actually went", () => { // A static render cannot press the button, so the claim is made about the - // code path instead: every field reset lives inside `onSuccess`. A reset - // that migrated into `onError` — or out of the callbacks entirely — would - // wipe a typed identifier and eight ticked sections on a failure the - // person is being asked to retry. - const start = INVITE_CARD.indexOf("onSuccess:"); - const split = INVITE_CARD.indexOf("onError:", start); - // The options object ends at the first ` },` after the error arm, so - // the slice stops at the mutation rather than running into the JSX below, - // where a `setNarrowed(false)` / `setNarrowed(true)` on a radio would - // satisfy the matcher for the wrong reason. The success reset returns the - // scope to unset (`null`), which the JSX never writes. - const end = INVITE_CARD.indexOf("\n },", split); + // code path instead: every field reset sits after the awaited invitation + // inside `try`, and none in the `catch`. A reset that migrated into the + // failure arm would wipe a typed identifier and eight ticked sections on + // a failure the person is being asked to retry. The failure arm ends at + // the end of the submit handler, before the JSX below, where a + // `setNarrowed(false)` on a radio would satisfy the matcher for the wrong + // reason. + const start = INVITE_CARD.indexOf("await recentProof.run("); + const split = INVITE_CARD.indexOf("} catch (err) {", start); + const end = INVITE_CARD.indexOf("\n };", split); const onSuccess = INVITE_CARD.slice(start, split); const onError = INVITE_CARD.slice(split, end); diff --git a/src/app/api/account/grants/__tests__/invite-access-level.test.ts b/src/app/api/account/grants/__tests__/invite-access-level.test.ts index 3798cc52f..431ad44e0 100644 --- a/src/app/api/account/grants/__tests__/invite-access-level.test.ts +++ b/src/app/api/account/grants/__tests__/invite-access-level.test.ts @@ -27,11 +27,19 @@ vi.mock("@/lib/api-handler", async () => { await vi.importActual( "@/lib/api-handler", ); + const requireAuth = vi.fn(); return { ...actual, apiHandler: Promise>(h: T): T => h, - requireAuth: vi.fn(), + requireAuth, + // The invitation takes the fresh-proof gate; here it resolves the caller + // the stubbed `requireAuth` names, so these cases stay about the row. + // The gate itself has its own case in `invite-recent-proof.test.ts`. + requireRecentProof: vi.fn(async () => ({ + ...(await requireAuth()), + commitElevation: async () => {}, + })), }; }); diff --git a/src/app/api/account/grants/__tests__/invite-recent-proof.test.ts b/src/app/api/account/grants/__tests__/invite-recent-proof.test.ts new file mode 100644 index 000000000..e4c987074 --- /dev/null +++ b/src/app/api/account/grants/__tests__/invite-recent-proof.test.ts @@ -0,0 +1,114 @@ +/** + * Offering access to the record asks for a fresh proof. + * + * An invitation outlives the session that sent it, so a live session alone + * was enough for a stolen one to leave a standing way into the record in the + * thief's own account. The real gate runs here; only the session, the + * database and the headers are stubbed. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + session: { findUnique: vi.fn() }, + webauthnMfaCredential: { count: vi.fn(async () => 0) }, + passkey: { count: vi.fn(async () => 0) }, + user: { findFirst: vi.fn() }, + accountGrant: { create: vi.fn(), findFirst: vi.fn() }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), + recordDelegatedAccess: vi.fn(), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/sharing/grants", async (importOriginal) => ({ + ...(await importOriginal()), + inviteGrant: vi.fn(), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { inviteGrant } from "@/lib/sharing/grants"; + +const OWNER = { + id: "owner-1", + role: "USER", + username: "owner", + totpConfirmedAt: null, + passwordHash: "argon", +}; + +function invite(): NextRequest { + return new NextRequest("http://localhost/api/account/grants", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ identifier: "housemate", access: "READ" }), + }); +} + +function signedIn(createdAt: Date) { + vi.mocked(getSession).mockResolvedValue({ + session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) }, + user: OWNER, + } as never); + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + createdAt, + mfaVerifiedAt: null, + reproofAt: null, + } as never); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("POST /api/account/grants — fresh proof", () => { + it("a session that signed in an hour ago is asked to confirm, and nothing is offered", async () => { + signedIn(new Date(Date.now() - 60 * 60 * 1000)); + const res = await POST(invite()); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.meta.errorCode).toBe("auth.reproof.required"); + expect(body.meta.methods).toEqual(["password"]); + expect(prisma.user.findFirst).not.toHaveBeenCalled(); + expect(inviteGrant).not.toHaveBeenCalled(); + }); + + it("a session that signed in a minute ago offers the invitation", async () => { + signedIn(new Date(Date.now() - 60 * 1000)); + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + id: "invitee-1", + username: "housemate", + displayName: null, + } as never); + vi.mocked(inviteGrant).mockResolvedValue({ + id: "grant-1", + access: "READ", + scopeJson: null, + invitedAt: new Date(), + acceptedAt: null, + expiresAt: null, + lastUsedAt: null, + revokedAt: null, + revokedBy: null, + } as never); + const res = await POST(invite()); + expect(res.status).toBe(201); + expect(inviteGrant).toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts b/src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts index 265283687..1d79bad00 100644 --- a/src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts +++ b/src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts @@ -31,11 +31,19 @@ vi.mock("@/lib/api-handler", async () => { await vi.importActual( "@/lib/api-handler", ); + const requireAuth = vi.fn(); return { ...actual, apiHandler: Promise>(h: T): T => h, - requireAuth: vi.fn(), + requireAuth, + // The invitation takes the fresh-proof gate; here it resolves the caller + // the stubbed `requireAuth` names, so these cases stay about the row. + // The gate itself has its own case in `invite-recent-proof.test.ts`. + requireRecentProof: vi.fn(async () => ({ + ...(await requireAuth()), + commitElevation: async () => {}, + })), requireFreshMfaIfEnrolled: vi.fn(), }; }); diff --git a/src/app/api/account/grants/route.ts b/src/app/api/account/grants/route.ts index 1a0184f55..0cae8e85d 100644 --- a/src/app/api/account/grants/route.ts +++ b/src/app/api/account/grants/route.ts @@ -21,9 +21,15 @@ * a code rather than leaving a token caller with an authentication error on a * request that authenticated fine. * - * **Neither is delegable, and that is structural.** Both resolve through bare - * `requireAuth()`, which refuses outright while the caller is acting on - * another account. So a delegate cannot invite anybody into the record they + * v1.39.3 — offering any level also asks for a fresh proof + * (`requireRecentProof`), and "sign out everywhere" withdraws the invitations + * nobody has accepted yet. An invitation outlives the session that sent it, so + * one offered from a stolen session would otherwise stay live after the owner + * signed everything else out. + * + * **Neither is delegable, and that is structural.** Both resolve through + * `requireAuth()` (the invitation through `requireRecentProof`, which calls + * it), which refuses outright while the caller is acting on another account. So a delegate cannot invite anybody into the record they * were given access to, cannot widen their own grant, and cannot pass it on: * the route does not have a re-delegation check that somebody could soften, it * has no code path that runs under a switch at all. The grantor is then taken @@ -45,6 +51,7 @@ import { apiHandler, requireAuth, requireFreshMfaIfEnrolled, + requireRecentProof, MFA_STEP_UP_MAX_AGE_SECONDS, } from "@/lib/api-handler"; import { @@ -122,7 +129,12 @@ export const GET = apiHandler(async () => { }); export const POST = apiHandler(async (request: NextRequest) => { - const auth = await requireAuth(); + // Offering access leaves a standing way into the record in somebody else's + // hands, one that outlives the session that offered it. So a live session is + // not enough: a sign-in or re-proof within five minutes on the browser, with + // the second factor on an account that has one, or a step-up elevation on a + // token. No shipped client offers access with a token. + const auth = await requireRecentProof({ bearer: "elevation" }); const user = auth.user; const rl = await checkRateLimit( @@ -194,6 +206,8 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("No account with that name or e-mail", 404); } + await auth.commitElevation(); + try { // `inviteGrant` decides everything about the row except the level: the // self-grant refusal, and the one-live-grant-per-pair rule the partial 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 977ab9e51..6490b756a 100644 --- a/src/app/api/auth/login/__tests__/constant-work.test.ts +++ b/src/app/api/auth/login/__tests__/constant-work.test.ts @@ -21,6 +21,16 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +// The per-account throttle has its own suite (login-throttle.test.ts); here +// it lets every attempt through and counts nothing. +vi.mock("@/lib/auth/login-throttle", () => ({ + beginAccountLoginAttempt: vi.fn(async () => ({ + waiting: null, + source: null, + failed: async () => {}, + succeeded: async () => {}, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { user: { findFirst: vi.fn() }, 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 73e5d82d9..231483378 100644 --- a/src/app/api/auth/login/__tests__/native-token.test.ts +++ b/src/app/api/auth/login/__tests__/native-token.test.ts @@ -3,6 +3,16 @@ import { NextRequest } from "next/server"; // --- Mocks must come before importing the route. --- +// The per-account throttle has its own suite (login-throttle.test.ts); here +// it lets every attempt through and counts nothing. +vi.mock("@/lib/auth/login-throttle", () => ({ + beginAccountLoginAttempt: vi.fn(async () => ({ + waiting: null, + source: null, + failed: async () => {}, + succeeded: async () => {}, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { user: { findFirst: vi.fn() }, diff --git a/src/app/api/auth/login/__tests__/route.test.ts b/src/app/api/auth/login/__tests__/route.test.ts index fb2df339b..6a8d06c6a 100644 --- a/src/app/api/auth/login/__tests__/route.test.ts +++ b/src/app/api/auth/login/__tests__/route.test.ts @@ -8,6 +8,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { NextRequest } from "next/server"; +// The per-account throttle has its own suite (login-throttle.test.ts); here +// it lets every attempt through and counts nothing. +vi.mock("@/lib/auth/login-throttle", () => ({ + beginAccountLoginAttempt: vi.fn(async () => ({ + waiting: null, + source: null, + failed: async () => {}, + succeeded: async () => {}, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { user: { findFirst: vi.fn() }, 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 c6405653a..95b7a3492 100644 --- a/src/app/api/auth/login/__tests__/trusted-device.test.ts +++ b/src/app/api/auth/login/__tests__/trusted-device.test.ts @@ -1,6 +1,16 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +// The per-account throttle has its own suite (login-throttle.test.ts); here +// it lets every attempt through and counts nothing. +vi.mock("@/lib/auth/login-throttle", () => ({ + beginAccountLoginAttempt: vi.fn(async () => ({ + waiting: null, + source: null, + failed: async () => {}, + succeeded: async () => {}, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { user: { findFirst: vi.fn() }, diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 2c07466cb..75dc974d6 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -9,12 +9,8 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; -import { - checkAuthSurfaceRateLimit, - checkRateLimit, - rateLimitHeaders, - refundRateLimit, -} from "@/lib/rate-limit"; +import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { beginAccountLoginAttempt } from "@/lib/auth/login-throttle"; import { ensureDbCompatibility } from "@/lib/db-compat"; import { NextRequest, NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; @@ -26,15 +22,6 @@ 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) @@ -105,27 +92,31 @@ 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) { + // Under an SSO-only policy the one password sign-in allowed is the one that + // confirms the account a pending single sign-on link names. Any other + // account is refused BEFORE its password is checked: refusing after the + // check answered "wrong password" and "right password, wrong account" + // differently, which made this exception an oracle for every other + // account's password. + if (pendingLink && pendingLink.userId !== user?.id) { + return apiError("Password login is disabled. Sign in with SSO.", 403, { + errorCode: "oidc_only", + }); + } + + // The per-account half of the throttle (`@/lib/auth/login-throttle`): a + // place this account has signed in from is neither counted nor held back, + // anywhere else waits longer after each failure past the first few, up to + // fifteen minutes. Decided before the Argon2id verification, so a waiting + // attempt costs no hashing. + const attempt = await beginAccountLoginAttempt({ + user, + identifier, + request, + ip: rl.ip, + }); + annotate({ meta: { login_known_source: attempt.source ?? "none" } }); + if (attempt.waiting) { await auditLog("auth.login.account_throttled", { userId: user?.id, ipAddress: ip, @@ -137,7 +128,7 @@ export const POST = apiHandler(async (request: NextRequest) => { data: null, error: "Too many login attempts. Please try again later.", }, - { status: 429, headers: rateLimitHeaders(accountRl) }, + { status: 429, headers: rateLimitHeaders(attempt.waiting) }, ); } @@ -154,6 +145,7 @@ export const POST = apiHandler(async (request: NextRequest) => { const valid = await verifyPasswordOrDummy(user?.passwordHash, password); if (!user || !user.passwordHash) { + await attempt.failed(); // v1.4.43 W3-SECURITY (H-1): never write the typed identifier into // the audit row — `reason` already tells the operator what // happened, and PII must not land in operator artefacts. The @@ -168,6 +160,7 @@ export const POST = apiHandler(async (request: NextRequest) => { } if (!valid) { + await attempt.failed(); await auditLog("auth.login.failed", { userId: user.id, ipAddress: ip, @@ -176,14 +169,8 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Invalid credentials", 401); } - if (pendingLink && pendingLink.userId !== user.id) { - return apiError("Password login is disabled. Sign in with SSO.", 403, { - errorCode: "oidc_only", - }); - } - // The password was right: this attempt was not a failed guess. - await refundRateLimit(accountBucket); + await attempt.succeeded(); const ua = request.headers.get("user-agent"); diff --git a/src/app/api/auth/me/mfa/totp/setup/route.ts b/src/app/api/auth/me/mfa/totp/setup/route.ts index e1ac624ea..9c94d919e 100644 --- a/src/app/api/auth/me/mfa/totp/setup/route.ts +++ b/src/app/api/auth/me/mfa/totp/setup/route.ts @@ -16,8 +16,10 @@ * factor that later satisfies every step-up gate, so a stolen session must not * be able to mint one: the cookie arm also passes `checkCookieEnrollmentProof` * — a sign-in or second factor inside the last five minutes, or a password / - * factor proof in the body (the step-up mint's shapes). The Bearer arm already - * carries that proof in its elevation. The recovery-code batch is + * factor proof in the body (the step-up mint's shapes); on an account that + * already has a second factor, only that factor or a passkey. The Bearer arm + * carries the proof in its elevation, under the same rule: on an account with + * a second factor the elevation must come from one. The recovery-code batch is * issued at `/confirm` (after the factor is proven), not here, so an abandoned * setup never persists codes. */ @@ -41,7 +43,7 @@ const SETUP_RATE_LIMIT = 5; const SETUP_WINDOW_MS = 15 * 60 * 1000; export const POST = apiHandler(async (req: Request) => { - const auth = await requireMfaManagementAuth(); + const auth = await requireMfaManagementAuth({ freshFactorIfEnrolled: true }); const { user } = auth; const rl = await checkRateLimit( diff --git a/src/app/api/auth/me/mfa/webauthn/register/options/route.ts b/src/app/api/auth/me/mfa/webauthn/register/options/route.ts index 998c2f3b5..ff09e4f79 100644 --- a/src/app/api/auth/me/mfa/webauthn/register/options/route.ts +++ b/src/app/api/auth/me/mfa/webauthn/register/options/route.ts @@ -8,7 +8,10 @@ * * The cookie arm additionally passes `checkCookieEnrollmentProof`: a session * alone cannot add a security key, for the same reason it cannot enroll TOTP — - * the new key would satisfy every step-up gate from then on. + * the new key would satisfy every step-up gate from then on. On an account that + * already has a second factor, both arms want that factor (or a passkey): a + * password, or a session that skipped the factor on a remembered browser, does + * not add a key. */ import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; @@ -20,7 +23,7 @@ import { createMfaRegistrationOptions } from "@/lib/auth/mfa/webauthn"; export const dynamic = "force-dynamic"; export const POST = apiHandler(async (req: Request) => { - const auth = await requireMfaManagementAuth(); + const auth = await requireMfaManagementAuth({ freshFactorIfEnrolled: true }); const { user } = auth; const rl = await checkRateLimit( 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 d19634e43..8f90a9de1 100644 --- a/src/app/api/auth/me/sessions/__tests__/route.test.ts +++ b/src/app/api/auth/me/sessions/__tests__/route.test.ts @@ -12,6 +12,7 @@ vi.mock("@/lib/db", () => ({ update: vi.fn(() => ({ catch: () => undefined })), }, user: { findUnique: vi.fn() }, + accountGrant: { findMany: vi.fn(async () => []) }, }, })); @@ -165,6 +166,7 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => { accessTokensRevoked: 0, connectorsRevoked: 0, shareLinksRevoked: 0, + pendingInvitesRevoked: 0, }); const res = await DELETE(del()); @@ -205,6 +207,7 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => { accessTokensRevoked: 1, connectorsRevoked: 0, shareLinksRevoked: 0, + pendingInvitesRevoked: 0, }); const res = await DELETE(del()); @@ -219,6 +222,53 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => { }); }); +describe("sign out everywhere and record sharing", () => { + it("reports withdrawn invitations and lists the accepted grants it kept", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(destroyOtherSessions).mockResolvedValue({ + sessionsRevoked: 1, + accessTokensRevoked: 0, + connectorsRevoked: 0, + shareLinksRevoked: 0, + pendingInvitesRevoked: 2, + }); + const party = { id: "user-2", username: "carer", displayName: null }; + const live = { + id: "grant-live", + access: "READ", + acceptedAt: new Date("2026-01-01"), + revokedAt: null, + expiresAt: null, + grantee: party, + }; + const lapsed = { + ...live, + id: "grant-lapsed", + expiresAt: new Date("2026-02-01"), + }; + vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([ + live, + lapsed, + ] as never); + + const res = await DELETE(del()); + const body = await res.json(); + expect(body.data.pendingInvitesRevoked).toBe(2); + // The lapsed one confers nothing any more and is not listed. + expect(body.data.grantsKept).toEqual([ + { id: "grant-live", account: party, access: "READ" }, + ]); + expect(prisma.accountGrant.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + grantorId: "user-1", + revokedAt: null, + }), + }), + ); + }); +}); + describe("DELETE /api/auth/me/sessions/[id]", () => { function ctx(id: string) { return { params: Promise.resolve({ id }) }; diff --git a/src/app/api/auth/me/sessions/route.ts b/src/app/api/auth/me/sessions/route.ts index 1b9b02c34..9f256b4f4 100644 --- a/src/app/api/auth/me/sessions/route.ts +++ b/src/app/api/auth/me/sessions/route.ts @@ -5,7 +5,10 @@ * connection and token, and (unless * `?keepShareLinks=1`) every clinician share * link, keeping the caller's current - * credential. Closes #64. + * credential. Closes #64. Also withdraws + * record-sharing invitations nobody has + * accepted yet, and lists the accepted + * grants it left standing (`grantsKept`). * * v1.23 — the user-facing session/device-management surface. Distinct from * `/api/auth/me/devices`, which lists APNs / Web-Push notification devices; @@ -34,6 +37,8 @@ import { type CurrentCredential, } from "@/lib/auth/session"; import { lookupIpLocation } from "@/lib/geo"; +import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view"; +import { isGrantActive } from "@/lib/sharing/grants"; import { coarseDeviceLabel, maskIp } from "@/lib/auth/device-fingerprint"; export const dynamic = "force-dynamic"; @@ -131,11 +136,32 @@ export const DELETE = apiHandler(async (request: NextRequest) => { accessTokensRevoked, connectorsRevoked, shareLinksRevoked, + pendingInvitesRevoked, } = await destroyOtherSessions(user.id, current, { reach: "everything", keepShareLinks, }); + // The accepted grants this account gave, still live. Not ended here: each is + // a person the owner chose, often a carer who still needs the record, and a + // button labelled "sign out" must not quietly cut them off. They are listed + // instead, so the person who just signed everything out can see who can + // still read the record and end any of them with one click. + const now = new Date(); + const grantsKept = ( + await prisma.accountGrant.findMany({ + where: { + grantorId: user.id, + acceptedAt: { not: null }, + revokedAt: null, + }, + orderBy: { acceptedAt: "desc" }, + include: { grantee: { select: GRANT_PARTY_SELECT } }, + }) + ) + .filter((g) => isGrantActive(g, now)) + .map((g) => ({ id: g.id, account: g.grantee, access: g.access })); + await auditLog("auth.session.revoke_others", { userId: user.id, ipAddress: getClientIp(request), @@ -144,6 +170,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => { accessTokensRevoked, connectorsRevoked, shareLinksRevoked, + pendingInvitesRevoked, + grantsKept: grantsKept.length, keepShareLinks, }, }); @@ -155,6 +183,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => { access_tokens_revoked: accessTokensRevoked, connectors_revoked: connectorsRevoked, share_links_revoked: shareLinksRevoked, + pending_invites_revoked: pendingInvitesRevoked, + grants_kept: grantsKept.length, }, }); @@ -163,6 +193,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => { accessTokensRevoked, connectorsRevoked, shareLinksRevoked, + pendingInvitesRevoked, + grantsKept, }); }); diff --git a/src/app/api/auth/passkey/register-options/route.ts b/src/app/api/auth/passkey/register-options/route.ts index ac848880c..b72bfff13 100644 --- a/src/app/api/auth/passkey/register-options/route.ts +++ b/src/app/api/auth/passkey/register-options/route.ts @@ -5,10 +5,16 @@ import { } from "@/lib/auth/passkey"; import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn"; import { + isSecondFactorProof, recordReproofFailure, + refundReproof, throttleReproof, verifyExistingFactorProof, } from "@/lib/auth/existing-factor-proof"; +import { + hasSecondFactorEnrolled, + recentProofMethods, +} from "@/lib/auth/second-factor"; import { prisma } from "@/lib/db"; import { apiError, @@ -55,6 +61,25 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = stepUpMintSchema.safeParse(body); if (!parsed.success) return proofRequired(); + // On an account with a second factor a password does not add a sign-in + // credential, exactly as it does not satisfy the second factor at sign-in: + // a passkey registered on the strength of the password alone would from then + // on sign in without the second factor at all. Refused before a guess is + // spent, with the proofs that would work. + if ( + !isSecondFactorProof(parsed.data.method) && + (await hasSecondFactorEnrolled(user)) + ) { + annotate({ + action: { name: "auth.reproof.method_refused" }, + meta: { stage: "passkey_enroll", method: parsed.data.method }, + }); + return apiError("Confirm with your second factor or a passkey", 422, { + errorCode: "auth.reproof.too_weak", + methods: await recentProofMethods(user, true), + }); + } + // Every proof counts against the account's shared re-proof bucket and every // refusal is audited. Without both, a stolen session could guess the account // password or a TOTP code here as fast as it could send requests. @@ -73,6 +98,7 @@ export const POST = apiHandler(async (request: NextRequest) => { ); return proofRequired(); } + await refundReproof(user.id); // A strong existing factor refreshes the session's MFA stamp. Password proof // deliberately clears it: registration is authorized by this single-use, diff --git a/src/app/api/auth/password/__tests__/route.test.ts b/src/app/api/auth/password/__tests__/route.test.ts index e13ecff29..557ac34e8 100644 --- a/src/app/api/auth/password/__tests__/route.test.ts +++ b/src/app/api/auth/password/__tests__/route.test.ts @@ -50,6 +50,7 @@ vi.mock("@/lib/auth/password", () => ({ vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn(), + refundRateLimit: vi.fn(), })); vi.mock("@/lib/i18n/server-locale", () => ({ @@ -77,7 +78,7 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; -import { checkRateLimit } from "@/lib/rate-limit"; +import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit"; import { verifyPassword, checkPasswordStrength } from "@/lib/auth/password"; import { requireFreshMfaIfEnrolled, @@ -146,6 +147,9 @@ describe("POST /api/auth/password — MFA step-up (v1.25)", () => { expect(res.status).toBe(200); expect(requireFreshMfaIfEnrolled).toHaveBeenCalledWith(expect.any(Number)); expect(prisma.user.update).toHaveBeenCalled(); + // The right current password was not a guess: the shared re-proof + // bucket gets its slot back. + expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:user-1"); }); }); diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts index 47906bc9f..a8935d912 100644 --- a/src/app/api/auth/password/route.ts +++ b/src/app/api/auth/password/route.ts @@ -16,6 +16,7 @@ import { } from "@/lib/api-response"; import { recordReproofFailure, + refundReproof, throttleReproof, } from "@/lib/auth/existing-factor-proof"; import { @@ -72,6 +73,10 @@ export const POST = apiHandler(async (request: NextRequest) => { ); return apiError("Current password is incorrect", 401); } + // The current password was right, so this attempt was not a guess. Give the + // shared re-proof budget its slot back; otherwise a password change followed + // by a few confirmations elsewhere runs the owner out of attempts. + await refundReproof(user.id); if (currentPassword === newPassword) { return apiError("New password must differ from current password", 422); diff --git a/src/app/api/auth/reproof/route.ts b/src/app/api/auth/reproof/route.ts index 0f39e7d13..f01203d96 100644 --- a/src/app/api/auth/reproof/route.ts +++ b/src/app/api/auth/reproof/route.ts @@ -52,6 +52,7 @@ import { annotate } from "@/lib/logging/context"; import { REPROOF_FAILED_CODE, recordReproofFailure, + refundReproof, throttleReproof, verifyExistingFactorProof, } from "@/lib/auth/existing-factor-proof"; @@ -110,6 +111,8 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } + await refundReproof(user.id); + const now = new Date(); await prisma.session.update({ where: { id: session.id }, 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 03631ffa8..45cb98d38 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -318,3 +318,42 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => { } }); }); + +describe("GET /api/export — the whole record asks for a fresh proof", () => { + const stale = { + createdAt: new Date(Date.now() - 60 * 60 * 1000), + mfaVerifiedAt: null, + reproofAt: null, + }; + + it("type=all on a session that signed in an hour ago is asked to confirm", async () => { + vi.mocked(prisma.session.findUnique).mockResolvedValueOnce(stale as never); + const { GET } = await import("../route"); + const res = await GET(mkReq("http://localhost/api/export?type=all")); + expect(res.status).toBe(401); + expect((await res.json()).meta.errorCode).toBe("auth.reproof.required"); + expect(prisma.measurement.findMany).not.toHaveBeenCalled(); + }); + + it("no type means the whole record, and is gated the same way", async () => { + vi.mocked(prisma.session.findUnique).mockResolvedValueOnce(stale as never); + const { GET } = await import("../route"); + const res = await GET(mkReq("http://localhost/api/export")); + expect(res.status).toBe(401); + }); + + it("type=all right after signing in goes through", async () => { + const { GET } = await import("../route"); + const res = await GET(mkReq("http://localhost/api/export?type=all")); + expect(res.status).toBe(200); + }); + + it("a single-type export stays on the session alone", async () => { + vi.mocked(prisma.session.findUnique).mockResolvedValue(stale as never); + const { GET } = await import("../route"); + const res = await GET( + mkReq("http://localhost/api/export?type=measurements"), + ); + expect(res.status).toBe(200); + }); +}); diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 9a761419c..664e6fc4f 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -32,6 +32,17 @@ vi.mock("@/lib/db", () => ({ userHealthProfile: { findUnique: vi.fn() }, healthProfileFactRevision: { findMany: vi.fn() }, user: { findUnique: vi.fn(), update: vi.fn() }, + // The whole-record gate reads the session's own stamps: a sign-in a + // moment ago is the proof, on an account without a second factor. + session: { + findUnique: vi.fn(async () => ({ + createdAt: new Date(), + mfaVerifiedAt: null, + reproofAt: null, + })), + }, + webauthnMfaCredential: { count: vi.fn(async () => 0) }, + passkey: { count: vi.fn(async () => 0) }, }, toJson: (value: unknown) => value, })); @@ -908,3 +919,18 @@ describe("POST /api/export/health-record — the insurance leaf", () => { expect(text).toContain("A123456789"); }); }); + +describe("POST /api/export/health-record — a fresh proof on the browser", () => { + it("a session that signed in an hour ago is asked to confirm first", async () => { + vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({ + createdAt: new Date(Date.now() - 60 * 60 * 1000), + mfaVerifiedAt: null, + reproofAt: null, + } as never); + const { POST } = await import("../route"); + const res = await POST(mkReq({ format: "pdf", selection: allLeaves() })); + expect(res.status).toBe(401); + expect((await res.json()).meta.errorCode).toBe("auth.reproof.required"); + expect(prisma.measurement.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts index 9937fa4b8..282da0731 100644 --- a/src/app/api/export/health-record/route.ts +++ b/src/app/api/export/health-record/route.ts @@ -22,7 +22,11 @@ */ import { NextRequest, NextResponse } from "next/server"; -import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { + apiHandler, + assertRecentCookieProof, + requireRecordAuth, +} from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -88,7 +92,17 @@ export const POST = apiHandler(async (request: NextRequest) => { // v1.37.0 — MANAGE: the doctor report is the one export an invited manager // reaches, because it renders a prepared artefact from a declared selection // rather than handing over the record whole. - const { user, actor } = await requireRecordAuth("manage", "record"); + const auth = await requireRecordAuth("manage", "record"); + const { user, actor } = auth; + // A whole-record artefact: a browser session must have signed in or + // re-proved within five minutes, the gate every whole-record export takes. + // The proof is the person at the keyboard, so it is the actor's session + // that is asked, whether they export their own record or one they manage. + // A token is its own proof here, as before: the shipped app calls this + // route with its token and no elevation. + if (auth.authMethod === "cookie") { + await assertRecentCookieProof(actor, auth.session.id); + } annotate({ action: { name: "export.health-record.build" } }); // The whole doctor-report surface is the `doctorReport` module. Refuse with diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index 82a2c133e..0e26e7560 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -1,5 +1,5 @@ import { prisma } from "@/lib/db"; -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 { apiError, getClientIp } from "@/lib/api-response"; @@ -24,12 +24,23 @@ import { NextRequest, NextResponse } from "next/server"; * Export user data. * Query params: * format: "csv" | "json" (default: json) - * type: "measurements" | "medications" | "intake" | "all" (default: all) + * type: "measurements" | "medications" | "intake" | "mood" | "all" + * (default: all) * - * Re-authentication is required via session (user must be logged in). + * `type=all` (the default) is the whole record in one file, so it takes the + * same fresh-proof gate as every other whole-record export + * (`requireRecentProof`): a browser session must have signed in or re-proved + * within five minutes, a token must present a step-up elevation. No shipped + * client calls this route with a token. The single-type exports stay on the + * session alone, like their dedicated routes under `/api/export/*`. */ export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const wholeRecord = + (new URL(request.url).searchParams.get("type") ?? "all") === "all"; + const auth = wholeRecord + ? await requireRecentProof({ bearer: "elevation" }) + : { ...(await requireAuth()), commitElevation: async () => {} }; + const { user } = auth; const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000); if (!rl.allowed) { @@ -54,6 +65,9 @@ export const GET = apiHandler(async (request: NextRequest) => { ); } + // Spend a Bearer elevation only once the request is about to act. + await auth.commitElevation(); + const userId = user.id; await auditLog("export.download", { userId, diff --git a/src/app/api/fhir/Patient/$everything/route.ts b/src/app/api/fhir/Patient/$everything/route.ts index bf596b780..68faf6304 100644 --- a/src/app/api/fhir/Patient/$everything/route.ts +++ b/src/app/api/fhir/Patient/$everything/route.ts @@ -13,12 +13,17 @@ * resolves inside the response and the operation can never quietly carry less * than the document does. * - * Read-only; `userId` narrowed from `requireAuth`. Offset paging applies across + * Read-only; `userId` narrowed from `requireAuth`. A browser session needs a + * recent sign-in or re-proof (`assertRecentCookieProof`). Offset paging applies across * the flattened resource list via `_count` (clamped ≤200) / `_offset`. */ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiHandler, + assertRecentCookieProof, + requireAuth, +} from "@/lib/api-handler"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -32,7 +37,16 @@ import { } from "@/lib/fhir/rest"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(FHIR_READ_SCOPE); + const auth = await requireAuth(FHIR_READ_SCOPE); + const { user } = auth; + // The whole record in one response: a browser session must have signed in + // or re-proved within five minutes, like every other whole-record export. + // A token is its own proof here, as before: the shipped app and the FHIR + // read tokens call this route without an elevation, and a refusal would + // read to the app as a dead session. + if (auth.authMethod === "cookie") { + await assertRecentCookieProof(user, auth.session.id); + } annotate({ action: { name: "fhir.everything.read" } }); // v1.30 — the FHIR REST face serves the SAME whole-record aggregate as diff --git a/src/app/api/fhir/__tests__/capability-and-everything.test.ts b/src/app/api/fhir/__tests__/capability-and-everything.test.ts index 22f1f077f..c402cf539 100644 --- a/src/app/api/fhir/__tests__/capability-and-everything.test.ts +++ b/src/app/api/fhir/__tests__/capability-and-everything.test.ts @@ -15,6 +15,21 @@ import { existsSync } from "node:fs"; import path from "node:path"; vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/db", () => ({ + prisma: { + // The whole-record gate reads the session's own stamps: a sign-in a + // moment ago is the proof, on an account without a second factor. + 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/rate-limit", () => ({ checkRateLimit: vi.fn() })); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ @@ -39,6 +54,7 @@ vi.mock("next/headers", () => ({ import { GET as metadataGet } from "../metadata/route"; import { GET as everythingGet } from "../Patient/$everything/route"; +import { prisma } from "@/lib/db"; import { GET as observationRead } from "../Observation/[id]/route"; import { GET as patientRead } from "../Patient/[id]/route"; import { GET as coverageRead } from "../Coverage/[id]/route"; @@ -291,6 +307,17 @@ describe("GET /api/fhir/{type}/{id} — read", () => { }); describe("GET /api/fhir/Patient/$everything", () => { + it("asks a browser session that signed in an hour ago to confirm first", async () => { + vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({ + createdAt: new Date(Date.now() - 60 * 60 * 1000), + mfaVerifiedAt: null, + reproofAt: null, + } as never); + const res = await everythingGet(req("/api/fhir/Patient/$everything")); + expect(res.status).toBe(401); + expect((await res.json()).meta.errorCode).toBe("auth.reproof.required"); + }); + it("returns every resource family the document bundle carries", async () => { const res = await everythingGet( req("/api/fhir/Patient/$everything?_count=200"), diff --git a/src/app/api/fhir/__tests__/module-gate.test.ts b/src/app/api/fhir/__tests__/module-gate.test.ts index 40d328758..eb97a9833 100644 --- a/src/app/api/fhir/__tests__/module-gate.test.ts +++ b/src/app/api/fhir/__tests__/module-gate.test.ts @@ -32,6 +32,17 @@ vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, cycleProfile: { findUnique: vi.fn() }, + // The whole-record gate reads the session's own stamps: a sign-in a + // moment ago is the proof, on an account without a second factor. + 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/modules/operator-availability", async (importOriginal) => { diff --git a/src/app/api/mcp/oauth/__tests__/authorize.test.ts b/src/app/api/mcp/oauth/__tests__/authorize.test.ts index 68142b8c1..164da251e 100644 --- a/src/app/api/mcp/oauth/__tests__/authorize.test.ts +++ b/src/app/api/mcp/oauth/__tests__/authorize.test.ts @@ -1,7 +1,7 @@ /** * Authorize endpoint — consent gate + audience + PKCE + redirect-URI binding. */ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; process.env.API_TOKEN_HMAC_KEY = "x".repeat(48); process.env.APP_URL = "https://health.example"; @@ -21,6 +21,22 @@ vi.mock("@/lib/rate-limit", () => ({ })), rateLimitHeaders: vi.fn(() => ({})), })); +// The consent reads the session's own proof stamps. By default the session +// signed in a moment ago, which is a recent proof on an account without a +// second factor; the cases below age it. +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) }, + }, +})); vi.mock("@/lib/app-settings", () => ({ isApiGloballyEnabled: vi.fn(async () => true), })); @@ -30,6 +46,7 @@ import { getSession } from "@/lib/auth/session"; import { isApiGloballyEnabled } from "@/lib/app-settings"; import { registerDcrClient } from "@/lib/mcp/oauth/clients"; import { s256Challenge } from "@/lib/mcp/oauth/pkce"; +import { prisma } from "@/lib/db"; const REDIRECT = "https://claude.ai/api/mcp/auth_callback"; const VERIFIER = "a".repeat(64); @@ -327,6 +344,82 @@ describe("POST /authorize — decision", () => { }); }); +describe("authorize — allowing a connection needs a recent proof", () => { + // A queued stale stamp a case did not read must not leak into the next one. + afterEach(() => { + vi.mocked(prisma.session.findUnique).mockReset(); + }); + + function staleSession() { + vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({ + createdAt: new Date(Date.now() - 60 * 60 * 1000), + mfaVerifiedAt: null, + reproofAt: null, + } as never); + } + const allow = { + response_type: "code", + client_id: CLIENT.clientId, + redirect_uri: REDIRECT, + code_challenge: CHALLENGE, + code_challenge_method: "S256", + scope: "health:read", + state: "xyz", + resource: RESOURCE, + decision: "allow", + }; + + it("GET shows a confirm link instead of the form when the session is an hour old", async () => { + signedIn(); + staleSession(); + const res = await GET(getReq(authorizeUrl()) as never); + expect(res.status).toBe(200); + const body = await res.text(); + expect(body).toMatch(/Confirm it is you/); + expect(body).not.toMatch(/name="decision"/); + const href = body.match(/href="([^"]+)"/)?.[1] ?? ""; + const next = + new URL(href.replace(/&/g, "&"), "https://x").searchParams.get( + "next", + ) ?? ""; + expect(href.startsWith("/confirm-access?next=")).toBe(true); + // Back to this exact consent request. + expect(next).toBe( + new URL(authorizeUrl()).pathname + new URL(authorizeUrl()).search, + ); + }); + + it("POST allow on an hour-old session mints nothing", async () => { + signedIn(); + staleSession(); + const res = await POST(postReq(allow) as never); + expect(res.status).toBe(401); + expect(res.headers.get("location")).toBeNull(); + expect(await res.text()).toMatch(/confirm-access\?next=/); + }); + + it("an account with a second factor needs the factor, not a young session", async () => { + vi.mocked(getSession).mockResolvedValue({ + session: { + id: "s1", + expiresAt: new Date(Date.now() + 1e6), + actingAsUserId: null, + recordEpoch: 0, + }, + user: { id: "user-1", totpConfirmedAt: new Date("2026-01-01") } as never, + }); + const res = await POST(postReq(allow) as never); + expect(res.status).toBe(401); + }); + + it("deny needs no proof", async () => { + signedIn(); + staleSession(); + const res = await POST(postReq({ ...allow, decision: "deny" }) as never); + expect(res.status).toBe(303); + }); +}); + describe("authorize CSP — the consent form may lead only to the validated redirect", () => { function csp(res: Response): string { return res.headers.get("content-security-policy") ?? ""; diff --git a/src/app/api/mcp/oauth/authorize/route.ts b/src/app/api/mcp/oauth/authorize/route.ts index 783940496..b0ad1ef5e 100644 --- a/src/app/api/mcp/oauth/authorize/route.ts +++ b/src/app/api/mcp/oauth/authorize/route.ts @@ -11,7 +11,9 @@ * a GET (confused-deputy mitigation: a third party cannot silently * obtain a grant — an authenticated human must click "Allow"). * POST — the consent decision. Requires a session, re-validates every - * parameter, and on "Allow" mints a single-use, short-lived, + * parameter, and on "Allow" asks for a recent sign-in or re-proof + * (the GET shows a confirm link instead of the form without one), + * then mints a single-use, short-lived, * self-describing authorization code bound to this user + client + * redirect URI + PKCE challenge + scope + audience, then 303s back. * @@ -30,6 +32,8 @@ import { annotate } from "@/lib/logging/context"; import { withBackgroundEvent } from "@/lib/logging/background"; import { auditLog } from "@/lib/auth/audit"; import { getSession } from "@/lib/auth/session"; +import { assertRecentCookieProof } from "@/lib/api-handler"; +import { StepUpRequiredError } from "@/lib/api-errors"; import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { isApiGloballyEnabled } from "@/lib/app-settings"; import { @@ -88,6 +92,42 @@ function html(body: string, status = 200): Response { ); } +/** + * Has this session signed in or re-proved recently enough to grant access? + * + * Allowing a connection hands a standing credential to the record to a third + * party, one that outlives the session: the same class of act as minting a + * token or making a share link, and it takes the same gate + * (`assertRecentCookieProof`: a sign-in or re-proof within five minutes, with + * the second factor on an account that has one). Without it, whoever held a + * stolen session could connect their own assistant and keep reading the + * record after the owner signed everything out. + */ +async function hasRecentProof( + session: NonNullable>>, +): Promise { + try { + await assertRecentCookieProof(session.user, session.session.id); + return true; + } catch (err) { + if (err instanceof StepUpRequiredError) return false; + throw err; + } +} + +/** + * The page shown instead of the consent form while the session carries no + * recent proof. It links to the confirm page, which runs the same re-proof + * dialog as the rest of the app and then comes back to this exact request. + */ +function reproofPage(clientName: string, returnTo: string, status = 200) { + const confirmHref = `/confirm-access?next=${encodeURIComponent(returnTo)}`; + return html( + `

Confirm it is you

Before ${htmlEscape(clientName)} can connect to your HealthLog data, confirm it is you. This is asked when you have not signed in or confirmed in the last five minutes.

Confirm and continue

`, + status, + ); +} + /** * The scopes this surface grants for a request. `health:read` is always * granted (the floor). `health:write` is granted ONLY when explicitly @@ -359,6 +399,11 @@ async function authorizeGet(request: NextRequest): Promise { ); } + if (!(await hasRecentProof(session))) { + annotate({ action: { name: "mcp.oauth.authorize.reproof_required" } }); + return reproofPage(v.clientName, url.pathname + url.search); + } + annotate({ action: { name: "mcp.oauth.authorize.consent_shown" } }); // Consent screen — a plain POST form (no inline script/style, CSP-safe). const hidden = (name: string, value: string) => @@ -499,6 +544,23 @@ async function authorizePost(request: NextRequest): Promise { }); } + // The same gate the consent page asked for, checked again on the decision + // itself: the page may have been left open past the window, or the POST + // built by hand. Denying needs no proof, so only "allow" is gated. + if (!(await hasRecentProof(session))) { + annotate({ action: { name: "mcp.oauth.authorize.reproof_required" } }); + const back = new URL("/api/mcp/oauth/authorize", request.url); + back.searchParams.set("response_type", "code"); + back.searchParams.set("client_id", v.clientId); + back.searchParams.set("redirect_uri", v.redirectUri); + back.searchParams.set("code_challenge", v.codeChallenge); + back.searchParams.set("code_challenge_method", "S256"); + back.searchParams.set("scope", v.scope); + back.searchParams.set("resource", v.resource); + if (v.state !== undefined) back.searchParams.set("state", v.state); + return reproofPage(v.clientName, back.pathname + back.search, 401); + } + const code = signArtifact( "authCode", { diff --git a/src/app/confirm-access/__tests__/return-to.test.ts b/src/app/confirm-access/__tests__/return-to.test.ts new file mode 100644 index 000000000..8f7f4555e --- /dev/null +++ b/src/app/confirm-access/__tests__/return-to.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { confirmReturnTo } from "../return-to"; + +describe("confirmReturnTo", () => { + it("returns to the connection consent it came from", () => { + expect( + confirmReturnTo("/api/mcp/oauth/authorize?client_id=a&state=b"), + ).toBe("/api/mcp/oauth/authorize?client_id=a&state=b"); + }); + + it.each([ + null, + "", + "/", + "/settings", + "https://evil.example/api/mcp/oauth/authorize?x=1", + "//evil.example/api/mcp/oauth/authorize?x=1", + "/api/mcp/oauth/authorize", + "/api/mcp/oauth/authorize/../token?x=1", + ])("goes nowhere for %s", (next) => { + expect(confirmReturnTo(next)).toBeNull(); + }); +}); diff --git a/src/app/confirm-access/confirm-access-client.tsx b/src/app/confirm-access/confirm-access-client.tsx new file mode 100644 index 000000000..ee369b423 --- /dev/null +++ b/src/app/confirm-access/confirm-access-client.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useState } from "react"; +import { Loader2, ShieldCheck } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + recentProofErrorMessage, + useRecentProof, +} from "@/components/settings/security-section/use-recent-proof"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; + +export function ConfirmAccessClient({ returnTo }: { returnTo: string | null }) { + const { t } = useTranslations(); + const recentProof = useRecentProof(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function confirm() { + if (!returnTo) return; + setBusy(true); + setError(null); + try { + // Answers 200 when the session's proof is recent, and otherwise the + // refusal the hook turns into the re-proof dialog. + await recentProof.run(() => apiGet("/api/auth/reproof")); + // A full navigation: the consent is a server page, not an app route. + window.location.assign(returnTo); + } catch (err) { + setError(recentProofErrorMessage(err, t("auth.confirmAccess.failed"))); + setBusy(false); + } + } + + return ( +
+
+
+ {returnTo ? ( + <> +

{t("auth.confirmAccess.body")}

+
+ +
+ + ) : ( +

{t("auth.confirmAccess.nothingToConfirm")}

+ )} + {error && ( +

+ {error} +

+ )} + {recentProof.dialog} +
+ ); +} diff --git a/src/app/confirm-access/page.tsx b/src/app/confirm-access/page.tsx new file mode 100644 index 000000000..b9885f7e7 --- /dev/null +++ b/src/app/confirm-access/page.tsx @@ -0,0 +1,21 @@ +import { ConfirmAccessClient } from "./confirm-access-client"; +import { confirmReturnTo } from "./return-to"; + +/** + * Confirm it is you before connecting an AI assistant. + * + * The connection consent (`/api/mcp/oauth/authorize`) is a plain server page + * with no script, so it cannot run the re-proof dialog itself. When the + * session has not signed in or re-proved within five minutes it links here; + * this page runs the same dialog as the rest of the app and then goes back to + * the consent request it came from. + */ +export default async function ConfirmAccessPage({ + searchParams, +}: { + searchParams: Promise<{ next?: string | string[] }>; +}) { + const { next } = await searchParams; + const returnTo = confirmReturnTo(typeof next === "string" ? next : null); + return ; +} diff --git a/src/app/confirm-access/return-to.ts b/src/app/confirm-access/return-to.ts new file mode 100644 index 000000000..166e8a49a --- /dev/null +++ b/src/app/confirm-access/return-to.ts @@ -0,0 +1,26 @@ +/** + * Where the confirm page may send the browser once the person has confirmed + * it is them. + * + * Only back to the connection consent it came from, never anywhere else: the + * page takes `next` from the address bar, so any path it accepted would be a + * same-origin redirect somebody else could point. The consent request carries + * its own parameters and re-validates all of them when it is loaded again. + */ +export const CONSENT_PATH = "/api/mcp/oauth/authorize"; + +export function confirmReturnTo( + next: string | null | undefined, +): string | null { + if (!next || !next.startsWith(`${CONSENT_PATH}?`)) return null; + try { + const base = "http://confirm.invalid"; + const resolved = new URL(next, base); + if (resolved.origin !== base || resolved.pathname !== CONSENT_PATH) { + return null; + } + return `${resolved.pathname}${resolved.search}`; + } catch { + return null; + } +} diff --git a/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx b/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx index 49bb21f75..faae601af 100644 --- a/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx +++ b/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx @@ -70,15 +70,13 @@ describe("grant invitation lapse date", () => { ), "utf8", ); - const onError = source.slice( - source.indexOf("onError: (err) => setError"), - source.indexOf( - " );\n };", - source.indexOf("onError: (err) => setError"), - ), - ); + // The failure arm of the submit: everything from its `catch` to the end + // of the handler. + const start = source.indexOf("} catch (err) {"); + expect(start).toBeGreaterThan(-1); + const onError = source.slice(start, source.indexOf("\n };", start)); - expect(onError).toContain("setError(t(inviteErrorKey(err)))"); + expect(onError).toContain("t(inviteErrorKey(err))"); expect(onError).not.toContain("setIdentifier"); expect(onError).not.toContain("setExpiresOn"); expect(source).toContain('role="alert"'); diff --git a/src/components/settings/access/grant-invite-card.tsx b/src/components/settings/access/grant-invite-card.tsx index 3b86108ea..d9202302c 100644 --- a/src/components/settings/access/grant-invite-card.tsx +++ b/src/components/settings/access/grant-invite-card.tsx @@ -18,6 +18,10 @@ import { useModuleEnabled } from "@/hooks/use-module-enabled"; import { ApiError } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { useInviteGrant } from "@/lib/queries/use-account-grants"; +import { + ReproofCancelledError, + useRecentProof, +} from "@/components/settings/security-section/use-recent-proof"; import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope"; import { DEFAULT_TIMEZONE, isValidTimezone } from "@/lib/tz/format"; import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock"; @@ -79,6 +83,7 @@ import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock"; export function GrantInviteCard() { const { t } = useTranslations(); const invite = useInviteGrant(); + const recentProof = useRecentProof(); const ownerTimezone = useInvitationOwnerTimezone(); const [identifier, setIdentifier] = useState(""); const [expiresOn, setExpiresOn] = useState(""); @@ -118,31 +123,40 @@ export function GrantInviteCard() { setError(null); }; - const submit = (event: React.FormEvent) => { + const submit = async (event: React.FormEvent) => { event.preventDefault(); const value = identifier.trim(); if (value.length === 0 || choice.blocked) return; setError(null); setInvited(null); - invite.mutate( - { - identifier: value, - access, - scope: choice.scope, - expiresAt: endOfDayIso(expiresOn, ownerTimezone), - }, - { - onSuccess: (grant) => { - setIdentifier(""); - setExpiresOn(""); - setAccess("READ"); - setNarrowed(null); - setSections([]); - setInvited(grant.account.username); - }, - onError: (err) => setError(t(inviteErrorKey(err))), - }, - ); + // Offering access hands somebody a standing way into the record, so the + // server asks for a fresh proof unless the session signed in or re-proved + // within five minutes; the dialog collects it and the invitation is sent + // again. + try { + const grant = await recentProof.run(() => + invite.mutateAsync({ + identifier: value, + access, + scope: choice.scope, + expiresAt: endOfDayIso(expiresOn, ownerTimezone), + }), + ); + setIdentifier(""); + setExpiresOn(""); + setAccess("READ"); + setNarrowed(null); + setSections([]); + setInvited(grant.account.username); + } catch (err) { + if (err instanceof ReproofCancelledError) return; + setError( + err instanceof ApiError && + err.meta?.errorCode === "auth.reproof.sign_in_again" + ? err.message + : t(inviteErrorKey(err)), + ); + } }; return ( @@ -159,7 +173,7 @@ export function GrantInviteCard() { "recordSharing.invite.needsAccount", )}`} /> -
+ void submit(event)} className="space-y-3">