From 9eae352bb10634e017e63dbc777d2dae3a7a72a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 23:12:59 +0200 Subject: [PATCH 1/7] feat(claim): hand over a managed profile through single sign-on POST /api/auth/claim/sso checks the handover link and the username and seals the link id, the username and a per-start binding into an hl_claim cookie. GET /api/auth/oidc/login?intent=claim carries the binding into the encrypted OIDC state, and the callback binds the provider subject to the profile in the claim transaction, refusing a subject or verified email another account holds, a link that is no longer usable, and a live session. The handover status and the claim preview name the redemption methods; a single-sign-on-only server hands over through the identity provider, and only the password claim stays refused there. --- docs/api/openapi.yaml | 258 ++++++++++-------- src/__tests__/contract-stub-inventory.test.ts | 3 +- src/__tests__/session-surface-guard.test.ts | 48 +++- .../api/auth/claim/__tests__/route.test.ts | 186 ++++++++++++- src/app/api/auth/claim/preview/route.ts | 13 +- src/app/api/auth/claim/route.ts | 2 +- src/app/api/auth/claim/sso/route.ts | 83 +++++- src/app/api/auth/oidc/callback/route.ts | 91 +++++- .../auth/oidc/login/__tests__/route.test.ts | 47 ++++ src/app/api/auth/oidc/login/route.ts | 55 +++- .../managed-profiles/[id]/handover/route.ts | 25 +- src/lib/managed-profiles/claim-cookie.ts | 111 ++++++++ src/lib/managed-profiles/claim-gate.ts | 51 +++- src/lib/managed-profiles/claim-sso.ts | 178 ++++++++++++ src/lib/managed-profiles/handover.ts | 107 +++++++- src/lib/openapi/error-codes.ts | 1 + src/lib/openapi/routes/account-sharing.ts | 2 +- src/lib/openapi/routes/auth.ts | 19 +- src/lib/validations/claim-sso.ts | 1 + .../managed-profile-handover.test.ts | 2 +- 20 files changed, 1075 insertions(+), 208 deletions(-) create mode 100644 src/lib/managed-profiles/claim-cookie.ts create mode 100644 src/lib/managed-profiles/claim-sso.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 43407e243..192fc35c0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1873,9 +1873,15 @@ paths: description: "Web only. Checks the link and the username, sets a sealed `hl_claim` cookie (10 minutes, `httpOnly`) and answers where to continue: `/api/auth/oidc/login?intent=claim`. The OIDC callback binds the identity provider's subject to the profile and signs the new owner in; a subject already bound to an account, or a verified email - another account holds, is refused there. Works on a single-sign-on-only instance. 404 `profile_claim.invalid` - (no usable link) or `profile_claim.sso_unavailable` (no identity provider configured); 409 `profile_claim.taken` - (username) or `auth.already_authenticated`; rate-limited per source address like the password claim." + another account holds, is refused there. Works on a single-sign-on-only instance. When the callback refuses, the + browser lands on `/auth/claim?error=` with reason `claim_expired` (the link was used, withdrawn, + replaced or expired in the meantime, or the cookie is missing, expired or belongs to another start), + `claim_sso_subject_bound`, `claim_email_taken`, `claim_username_taken` (taken during the round trip), + `claim_signed_in`, or one of the sign-in reasons (`oidc_denied`, `oidc_state`, `oidc_failed`, + `oidc_rate_limited`, `oidc_disabled`); on success it lands on `/onboarding/handover`, where the new owner + decides each guardian's access. The cookie is cleared either way. 404 `profile_claim.invalid` (no usable link) + or `profile_claim.sso_unavailable` (no identity provider configured); 409 `profile_claim.taken` (username) or + `auth.already_authenticated`; rate-limited per source address like the password claim." requestBody: required: true content: @@ -1904,15 +1910,6 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": &a30 - description: "Not implemented on this server yet: the contract is published, the effect is not. `meta.errorCode` = - `not_implemented`, `meta.operation` names the operation. Authentication, module gates and request validation - already answer as documented. A client reaches this only by ignoring the server feature flag that guards the - operation." - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" /api/auth/claim/preview: post: security: [] @@ -1921,7 +1918,8 @@ paths: summary: Preview a managed-profile handover link description: "What a handover link would hand over: the profile's display name, the link's expiry, and each guardian's proposal. Anonymous; the token travels in the body. An unknown, expired, used or withdrawn token, and a link - whose creator lost access, all answer the same 404. Rate-limited per source address." + whose creator lost access, all answer the same 404. Rate-limited per source address. Since v1.43 answered on a + single-sign-on-only instance too, with `methods` naming the ways in." requestBody: required: true content: @@ -1936,12 +1934,6 @@ paths: schema: $ref: "#/components/schemas/ProfileClaimPreviewEnvelope" "401": *a1 - "403": - description: "The instance allows single sign-on only (`meta.errorCode: profile_claim.oidc_only_unsupported`)." - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" "404": description: "No usable link behind this token (`meta.errorCode: profile_claim.invalid`). Every failure class answers identically." @@ -3738,6 +3730,19 @@ paths: type: string description: "Browser leg only: where to land after login. Sanitised to an in-app path; ignored entirely on the native leg." + - in: query + name: intent + schema: + description: "v1.43 — `claim` continues a managed-profile claim started at POST /api/auth/claim/sso; the link and + username ride the sealed `hl_claim` cookie that call set, never this URL. Without a valid cookie for a + still-usable link the start redirects to `/auth/claim?error=claim_expired`. Browser leg only: with + `client=native` it fails with `oidc_invalid_request`. `next` is ignored." + type: string + const: claim + description: "v1.43 — `claim` continues a managed-profile claim started at POST /api/auth/claim/sso; the link and + username ride the sealed `hl_claim` cookie that call set, never this URL. Without a valid cookie for a + still-usable link the start redirects to `/auth/claim?error=claim_expired`. Browser leg only: with + `client=native` it fails with `oidc_invalid_request`. `next` is ignored." - in: query name: client_proto schema: @@ -3762,7 +3767,8 @@ paths: description: "Browser leg only: `location.host` of that page, read together with `client_proto`." responses: "302": - description: Always a redirect. To the provider on success. On failure to `/auth/login?error=` (browser) or + description: Always a redirect. To the provider on success. On failure to `/auth/login?error=` (browser; + `/auth/claim?error=` with `intent=claim`, where `claim_expired` is one more reason) or `healthlog://oidc-callback?error=` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`, and on the browser leg `insecure_transport` (see `client_proto`). The provider then returns to the callback (not listed here, because the identity provider @@ -15171,7 +15177,7 @@ paths: schema: $ref: "#/components/schemas/OcrTextExtractRequest" responses: - "202": &a39 + "202": &a38 description: Queued for the background worker. Every refusal the route can answer quickly (capability, provider, rate limit, budget, validation) has already been answered; anything that goes wrong from here is reported on the run. @@ -15453,7 +15459,15 @@ paths: "409": *a7 "422": *a4 "429": *a12 - "501": *a30 + "501": &a45 + description: "Not implemented on this server yet: the contract is published, the effect is not. `meta.errorCode` = + `not_implemented`, `meta.operation` names the operation. Authentication, module gates and request validation + already answer as documented. A client reaches this only by ignoring the server feature flag that guards the + operation." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/encounters: get: tags: @@ -15666,7 +15680,7 @@ paths: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 "403": *a11 - "404": &a31 + "404": &a30 description: Visit not found (or owned by another user). content: application/json: @@ -15700,7 +15714,7 @@ paths: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 "403": *a11 - "404": *a31 + "404": *a30 "422": *a4 "429": *a5 delete: @@ -15725,7 +15739,7 @@ paths: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 "403": *a11 - "404": *a31 + "404": *a30 "422": *a4 "429": *a5 /api/encounters/{id}/restore: @@ -15751,7 +15765,7 @@ paths: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 "403": *a11 - "404": *a31 + "404": *a30 "422": *a4 "429": *a5 /api/encounters/{id}/links: @@ -15781,7 +15795,7 @@ paths: schema: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 - "404": *a31 + "404": *a30 "422": *a4 "429": *a5 delete: @@ -15810,7 +15824,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 - "404": *a31 + "404": *a30 "422": *a4 "429": *a5 /api/practitioners: @@ -15889,7 +15903,7 @@ paths: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 "403": *a11 - "404": &a32 + "404": &a31 description: Practitioner not found (or owned by another user). content: application/json: @@ -15923,7 +15937,7 @@ paths: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 "403": *a11 - "404": *a32 + "404": *a31 "422": *a4 "429": *a5 delete: @@ -15947,7 +15961,7 @@ paths: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 "403": *a11 - "404": *a32 + "404": *a31 "422": *a4 "429": *a5 /api/practitioners/{id}/restore: @@ -15973,7 +15987,7 @@ paths: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 "403": *a11 - "404": *a32 + "404": *a31 "422": *a4 "429": *a5 /api/vaccinations/custom: @@ -16017,7 +16031,7 @@ paths: $ref: "#/components/schemas/CreateCustomVaccineEnvelope" "401": *a1 "403": *a11 - "409": &a33 + "409": &a32 description: A live definition on the record already carries this name, compared without case (`vaccination.custom.name-taken`). content: @@ -16054,13 +16068,13 @@ paths: $ref: "#/components/schemas/UpdateCustomVaccineEnvelope" "401": *a1 "403": *a11 - "404": &a34 + "404": &a33 description: No live definition with this id on the record (`vaccination.custom.not-found`). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "409": *a33 + "409": *a32 "422": *a4 "429": *a5 delete: @@ -16085,7 +16099,7 @@ paths: $ref: "#/components/schemas/DeleteCustomVaccineEnvelope" "401": *a1 "403": *a11 - "404": *a34 + "404": *a33 "422": *a4 "429": *a5 /api/vaccinations: @@ -16187,7 +16201,7 @@ paths: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 "403": *a11 - "404": &a35 + "404": &a34 description: Vaccination not found (or owned by another user). content: application/json: @@ -16222,7 +16236,7 @@ paths: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 "403": *a11 - "404": *a35 + "404": *a34 "422": *a4 "429": *a5 delete: @@ -16247,7 +16261,7 @@ paths: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 "403": *a11 - "404": *a35 + "404": *a34 "422": *a4 "429": *a5 /api/vaccinations/{id}/restore: @@ -16272,7 +16286,7 @@ paths: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 "403": *a11 - "404": *a35 + "404": *a34 "422": *a4 "429": *a5 /api/vaccinations/{id}/booster: @@ -16338,7 +16352,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": *a35 + "404": *a34 "422": *a4 "429": *a5 /api/vaccinations/suggest: @@ -16396,7 +16410,7 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a35 + "404": *a34 "422": *a4 "429": *a5 delete: @@ -16425,7 +16439,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a35 + "404": *a34 "422": *a4 "429": *a5 /api/illness/episodes: @@ -16540,7 +16554,7 @@ paths: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": &a36 + "404": &a35 description: Illness episode not found (or owned by another user). content: application/json: @@ -16575,7 +16589,7 @@ paths: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": *a36 + "404": *a35 "422": *a4 "429": *a5 delete: @@ -16599,7 +16613,7 @@ paths: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": *a36 + "404": *a35 "422": *a4 "429": *a5 /api/illness/episodes/{id}/restore: @@ -16627,7 +16641,7 @@ paths: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": *a36 + "404": *a35 "409": *a7 "422": *a4 "429": *a5 @@ -16660,7 +16674,7 @@ paths: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": *a36 + "404": *a35 "422": *a4 "429": *a5 /api/illness/episodes/{id}/day-logs: @@ -16711,7 +16725,7 @@ paths: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 "403": *a11 - "404": *a36 + "404": *a35 "422": *a4 "429": *a5 post: @@ -16748,7 +16762,7 @@ paths: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 "403": *a11 - "404": *a36 + "404": *a35 "422": *a4 "429": *a5 /api/illness/episodes/{id}/correlation: @@ -16775,7 +16789,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a36 + "404": *a35 "422": *a4 "429": *a5 /api/illness/insights: @@ -16991,7 +17005,7 @@ paths: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 "403": *a11 - "404": &a37 + "404": &a36 description: Allergy not found (or owned by another user). content: application/json: @@ -17025,7 +17039,7 @@ paths: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 "403": *a11 - "404": *a37 + "404": *a36 "422": *a4 "429": *a5 delete: @@ -17049,7 +17063,7 @@ paths: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 "403": *a11 - "404": *a37 + "404": *a36 "422": *a4 "429": *a5 /api/family-history: @@ -17123,7 +17137,7 @@ paths: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 "403": *a11 - "404": &a38 + "404": &a37 description: Family history entry not found (or owned by another user). content: application/json: @@ -17157,7 +17171,7 @@ paths: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 "403": *a11 - "404": *a38 + "404": *a37 "422": *a4 "429": *a5 delete: @@ -17181,7 +17195,7 @@ paths: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 "403": *a11 - "404": *a38 + "404": *a37 "422": *a4 "429": *a5 /api/documents/inbound: @@ -17846,7 +17860,7 @@ paths: required: true schema: type: string - - &a40 + - &a39 name: Prefer in: header required: false @@ -17871,9 +17885,9 @@ paths: application/json: schema: $ref: "#/components/schemas/InboundExtractEnvelope" - "202": *a39 + "202": *a38 "401": *a1 - "403": &a41 + "403": &a40 description: "AI refusal: the `documentAi` capability is unavailable for this record. The envelope carries `meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). `assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master @@ -17897,7 +17911,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a42 + "503": &a41 description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. Nothing was sent. Retry later." content: @@ -17927,7 +17941,7 @@ paths: required: true schema: type: string - - *a40 + - *a39 requestBody: required: false content: @@ -17963,12 +17977,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentSuggestEnvelope" - "202": *a39 + "202": *a38 "401": *a1 - "403": *a41 + "403": *a40 "422": *a4 "429": *a5 - "503": *a42 + "503": *a41 /api/documents/inbound/{id}/summary: post: tags: @@ -18018,7 +18032,7 @@ paths: type: boolean default: false description: With `persist=true`, explicitly replace an existing stored summary. - - *a40 + - *a39 requestBody: required: false content: @@ -18054,12 +18068,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentSummaryEnvelope" - "202": *a39 + "202": *a38 "401": *a1 - "403": *a41 + "403": *a40 "422": *a4 "429": *a5 - "503": *a42 + "503": *a41 /api/documents/inbound/{id}/index: post: tags: @@ -18085,7 +18099,7 @@ paths: required: true schema: type: string - - *a40 + - *a39 requestBody: required: false content: @@ -18121,12 +18135,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentIndexEnvelope" - "202": *a39 + "202": *a38 "401": *a1 - "403": *a41 + "403": *a40 "422": *a4 "429": *a5 - "503": *a42 + "503": *a41 /api/documents/inbound/{id}/chat: post: tags: @@ -18166,10 +18180,10 @@ paths: type: string description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." "401": *a1 - "403": *a41 + "403": *a40 "422": *a4 "429": *a5 - "503": *a42 + "503": *a41 get: tags: - Documents @@ -18232,10 +18246,10 @@ paths: schema: $ref: "#/components/schemas/DocumentReindexEnvelope" "401": *a1 - "403": *a41 + "403": *a40 "422": *a4 "429": *a5 - "503": *a42 + "503": *a41 /api/documents/inbound/{id}/facts/{factId}: patch: tags: @@ -18342,7 +18356,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a44 + "409": &a43 description: A custom metric with this name already exists for the caller. content: application/json: @@ -18371,7 +18385,7 @@ paths: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 "403": *a11 - "404": &a43 + "404": &a42 description: Custom metric not found (or owned by another user). content: application/json: @@ -18405,8 +18419,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a43 - "409": *a44 + "404": *a42 + "409": *a43 "422": *a4 "429": *a5 delete: @@ -18429,7 +18443,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a43 + "404": *a42 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -18473,7 +18487,7 @@ paths: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 "403": *a11 - "404": *a43 + "404": *a42 "422": *a4 "429": *a5 post: @@ -18502,7 +18516,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a43 + "404": *a42 "409": *a7 "422": *a4 "429": *a12 @@ -18538,7 +18552,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a45 + "404": &a44 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -18571,7 +18585,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a45 + "404": *a44 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -18895,7 +18909,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a30 + "501": *a45 /api/nutrients/batch: post: tags: @@ -20883,7 +20897,9 @@ paths: $ref: "#/components/schemas/ManagedProfileHandoverEnvelope" "401": *a1 "403": - description: "The instance allows single sign-on only (`meta.errorCode: profile_claim.oidc_only_unsupported`)." + description: "Neither a password nor single sign-on could redeem a link on this instance (`meta.errorCode: + profile_claim.oidc_only_unsupported`). Since v1.43 a single-sign-on-only instance hands over through the + identity provider, so a working configuration never answers this." content: application/json: schema: @@ -23992,7 +24008,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "501": *a30 + "501": *a45 /api/medications/{id}/ingredients: put: tags: @@ -24034,7 +24050,7 @@ paths: "409": *a7 "422": *a4 "429": *a5 - "501": *a30 + "501": *a45 /api/medications/{id}/ingredients/extract: post: tags: @@ -24064,7 +24080,7 @@ paths: required: - file responses: - "202": *a39 + "202": *a38 "401": *a1 "403": *a19 "413": @@ -24075,7 +24091,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a30 + "501": *a45 "503": description: "`aiRuns.workerUnavailable`: the run could not be handed to the background worker. Nothing was read and nothing was charged; try again." @@ -24100,7 +24116,7 @@ paths: "403": *a11 "422": *a4 "429": *a5 - "501": *a30 + "501": *a45 /api/anamnesis/life-circumstances/ai-sections: patch: tags: @@ -24130,7 +24146,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a30 + "501": *a45 /api/anamnesis/life-circumstances/confirm: post: tags: @@ -24154,7 +24170,7 @@ paths: "401": *a1 "422": *a4 "429": *a5 - "501": *a30 + "501": *a45 /api/anamnesis/audit-c: post: tags: @@ -24187,7 +24203,7 @@ paths: "409": *a7 "422": *a4 "429": *a5 - "501": *a30 + "501": *a45 /api/anamnesis/audit-c/{id}: delete: tags: @@ -24216,7 +24232,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a30 + "501": *a45 /api/dawarich/connect: post: tags: @@ -24242,7 +24258,7 @@ paths: "403": *a19 "422": *a4 "429": *a5 - "501": *a30 + "501": *a45 /api/dawarich/status: get: tags: @@ -24259,7 +24275,7 @@ paths: "403": *a19 "422": *a4 "429": *a5 - "501": *a30 + "501": *a45 /api/dawarich/places: get: tags: @@ -24290,7 +24306,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a30 + "501": *a45 "502": description: "`dawarich.unreachable`." content: @@ -24320,7 +24336,7 @@ paths: "403": *a19 "422": *a4 "429": *a5 - "501": *a30 + "501": *a45 /api/dawarich/test: post: tags: @@ -24339,7 +24355,7 @@ paths: "403": *a19 "422": *a4 "429": *a5 - "501": *a30 + "501": *a45 /api/dawarich/sync: post: tags: @@ -24374,7 +24390,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a30 + "501": *a45 /api/dawarich/disconnect: post: tags: @@ -24403,7 +24419,7 @@ paths: "403": *a19 "422": *a4 "429": *a5 - "501": *a30 + "501": *a45 /api/environment/travel/suggestions: get: tags: @@ -24420,7 +24436,7 @@ paths: "403": *a19 "422": *a4 "429": *a5 - "501": *a30 + "501": *a45 /api/environment/travel/suggestions/{id}/dismiss: post: tags: @@ -24450,7 +24466,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a30 + "501": *a45 /api/medications/{id}/links: post: tags: @@ -24487,7 +24503,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a30 + "501": *a45 delete: tags: - Links @@ -24523,7 +24539,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a30 + "501": *a45 /api/labs/{id}/links: post: tags: @@ -24560,7 +24576,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a30 + "501": *a45 delete: tags: - Links @@ -24596,7 +24612,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a30 + "501": *a45 /api/bugreport: post: security: [] @@ -35592,17 +35608,17 @@ components: personal_records.invalid_query; practitioner — practitioner.invalid; profile — profile.update.emailInUse, profile.update.emailRateLimited, profile.update.emailSecondFactorRequired, profile.update.invalidBody, profile.update.nothingSaved; profile_claim — profile_claim.invalid, - profile_claim.oidc_only_unsupported, profile_claim.taken; 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; search — search.invalid; 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, + profile_claim.oidc_only_unsupported, profile_claim.sso_unavailable, profile_claim.taken; 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; search — + search.invalid; 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; symptoms — symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid; diff --git a/src/__tests__/contract-stub-inventory.test.ts b/src/__tests__/contract-stub-inventory.test.ts index 0d71bcd34..6c39536c3 100644 --- a/src/__tests__/contract-stub-inventory.test.ts +++ b/src/__tests__/contract-stub-inventory.test.ts @@ -37,7 +37,6 @@ const CONTRACT_STUBS: Readonly> = { "anamnesis.life_circumstances.confirm", ], "anamnesis/life-circumstances/route.ts": ["anamnesis.life_circumstances.get"], - "auth/claim/sso/route.ts": ["profile_claim.sso.start"], "auth/me/ai-usage/route.ts": ["auth.me.ai_usage.put"], "biomarkers/[id]/merge/route.ts": ["labs.biomarker.merge"], "dawarich/connect/route.ts": ["dawarich.connect"], @@ -63,7 +62,7 @@ const CONTRACT_STUBS: Readonly> = { }; /** The size of the register when it was written; see the header. */ -const REGISTER_SIZE = 23; +const REGISTER_SIZE = 22; function routeFiles(): string[] { return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] }) diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts index d27126a2b..6fdcd8534 100644 --- a/src/__tests__/session-surface-guard.test.ts +++ b/src/__tests__/session-surface-guard.test.ts @@ -100,9 +100,11 @@ describe("S1 — the direct session-resolution set is frozen", () => { "app/api/auth/claim/sso/route.ts": "refuses to start a single-sign-on claim over a live session", // Anonymous IdP callback. Probes for an already-valid session so a - // replayed callback redirects instead of minting a second identity. + // replayed callback redirects instead of minting a second identity, and + // (v1.43, #959) so a single-sign-on claim is refused over a live session, + // as the claim routes refuse it. "app/api/auth/oidc/callback/route.ts": - "duplicate-callback probe on an otherwise anonymous route", + "duplicate-callback probe and the claim's live-session refusal on an otherwise anonymous route", // Public liveness probe. The session read only decides whether the body // carries operational detail; the status code is computed without it. "app/api/health/route.ts": "admin-only detail on a public probe", @@ -370,6 +372,48 @@ describe("S2 — the URL-token authentication set is frozen", () => { }); }); +describe("S4 — the single-sign-on claim cookie set is frozen", () => { + /** + * v1.43 (#959). A third way to act without a session: the sealed `hl_claim` + * cookie carries a managed-profile claim from `POST /api/auth/claim/sso` + * through the OIDC round trip, and the callback turns it — together with + * the provider's subject — into somebody's account. It is neither a session + * nor a URL credential, so neither leg above sees it. + * + * The handover token itself still only travels in the claim routes' bodies + * and the claim page's query string (S2); the cookie names the link by id. + * What this freezes is who may mint the cookie and who may act on it. + */ + const CLAIM_COOKIE_ALLOWLIST: Record = { + "app/api/auth/claim/sso/route.ts": + "mints the cookie, only after checking the token and the username", + "app/api/auth/oidc/login/route.ts": + "requires the cookie for intent=claim and binds it into the OIDC state", + "app/api/auth/oidc/callback/route.ts": + "binds the provider's subject to the profile the cookie names", + }; + + const CLAIM_COOKIE_USE = + /\b(readClaimCookie|setClaimCookie|claimMatchesBinding|clearClaimCookie)\s*\(|\bCLAIM_COOKIE\b|["']hl_claim["']/; + + it("no file under src/app touches the claim cookie unlisted", () => { + const users = appFilesMatching(CLAIM_COOKIE_USE); + expect(users.length).toBeGreaterThan(0); + expect(users).toEqual(Object.keys(CLAIM_COOKIE_ALLOWLIST).sort()); + }); + + it("only the single-sign-on start mints it", () => { + const minters = appFilesMatching(/\bsetClaimCookie\s*\(/); + expect(minters).toEqual(["app/api/auth/claim/sso/route.ts"]); + }); + + it("every allowlist entry carries a reason", () => { + for (const [rel, reason] of Object.entries(CLAIM_COOKIE_ALLOWLIST)) { + expect(reason.trim().length, `${rel} has no reason`).toBeGreaterThan(0); + } + }); +}); + describe("S3 — the two families stay distinct", () => { /** * A file that both resolves a session AND authenticates by a URL token is diff --git a/src/app/api/auth/claim/__tests__/route.test.ts b/src/app/api/auth/claim/__tests__/route.test.ts index b5b9722b1..49d2fc9c4 100644 --- a/src/app/api/auth/claim/__tests__/route.test.ts +++ b/src/app/api/auth/claim/__tests__/route.test.ts @@ -5,7 +5,11 @@ * (`tests/integration/managed-profile-handover.test.ts`); here the order of * the refusals is pinned, because each one is a security property: * - * - single sign-on only refuses before anything else; + * - single sign-on only refuses the PASSWORD claim before anything else, + * while the preview answers there and names `sso` as the way in (v1.43); + * - the single-sign-on start needs a configured provider, refuses a live + * session before the rate limit, checks the link before the username, + * and seals the link's id (never the token) into `hl_claim`; * - a live session is refused BEFORE the rate limit (a state, not an * attempt), and before any token is read; * - the rate limit runs before the body is parsed; @@ -24,7 +28,18 @@ vi.mock("@/lib/auth/session", () => ({ createSession: vi.fn(), getSession: vi.fn(), })); -vi.mock("@/lib/auth/oidc", () => ({ isOidcOnly: vi.fn() })); +vi.mock("@/lib/auth/oidc", () => ({ + isOidcOnly: vi.fn(), + isOidcConfigured: vi.fn(), + getOidcConfig: vi.fn(), +})); +vi.mock("@/lib/crypto", () => ({ + encrypt: vi.fn((s: string) => `sealed(${s})`), + decrypt: vi.fn(), +})); +vi.mock("@/lib/auth/secure-cookie", () => ({ + shouldEmitSecureCookie: () => true, +})); vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn(), checkAuthSurfaceRateLimit: vi.fn(), @@ -49,6 +64,7 @@ vi.mock("@/lib/managed-profiles/handover", async () => { return { HandoverError: actual.HandoverError, previewHandover: vi.fn(), + usableHandoverForToken: vi.fn(), claimManagedProfile: vi.fn(), }; }); @@ -67,9 +83,11 @@ vi.mock("next/headers", () => ({ import { POST as claim } from "../route"; import { POST as preview } from "../preview/route"; +import { POST as claimSso } from "../sso/route"; import { prisma } from "@/lib/db"; import { createSession, getSession } from "@/lib/auth/session"; -import { isOidcOnly } from "@/lib/auth/oidc"; +import { getOidcConfig, isOidcConfigured, isOidcOnly } from "@/lib/auth/oidc"; +import { auditLog } from "@/lib/auth/audit"; import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; import { checkPasswordStrength, hashPassword } from "@/lib/auth/password"; import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check"; @@ -77,6 +95,7 @@ import { claimManagedProfile, HandoverError, previewHandover, + usableHandoverForToken, } from "@/lib/managed-profiles/handover"; import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify"; @@ -105,6 +124,12 @@ const PREVIEW = { beforeEach(() => { vi.resetAllMocks(); vi.mocked(isOidcOnly).mockReturnValue(false); + vi.mocked(isOidcConfigured).mockReturnValue(false); + vi.mocked(getOidcConfig).mockReturnValue(null); + vi.mocked(usableHandoverForToken).mockResolvedValue({ + handoverId: "h1", + profileId: "p1", + }); vi.mocked(getSession).mockResolvedValue(null as never); vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ allowed: true, @@ -131,19 +156,62 @@ async function errorCode(res: Response): Promise { return body.meta?.errorCode; } +const OIDC_CONFIG = { + issuerUrl: "https://idp.example.test", + clientId: "c", + clientSecret: "s", + scopes: "openid email", + buttonLabel: "Example ID", +}; + +function singleSignOnOnly(): void { + vi.mocked(isOidcOnly).mockReturnValue(true); + vi.mocked(isOidcConfigured).mockReturnValue(true); + vi.mocked(getOidcConfig).mockReturnValue(OIDC_CONFIG); +} + +describe("on a single-sign-on-only instance", () => { + it("refuses the password claim before anything else", async () => { + singleSignOnOnly(); + vi.mocked(getSession).mockResolvedValue({ user: { id: "u1" } } as never); + const res = await claim(post("/api/auth/claim", BODY)); + expect(res.status).toBe(403); + expect(await errorCode(res)).toBe("profile_claim.oidc_only_unsupported"); + expect(previewHandover).not.toHaveBeenCalled(); + }); + + it("answers the preview and names single sign-on as the only way in", async () => { + singleSignOnOnly(); + const res = await preview( + post("/api/auth/claim/preview", { token: TOKEN }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: Record }; + expect(body.data.methods).toEqual(["sso"]); + expect(body.data.ssoProviderName).toBe("Example ID"); + }); +}); + describe.each([ ["claim", claim, "/api/auth/claim", BODY], ["preview", preview, "/api/auth/claim/preview", { token: TOKEN }], + ["sso", claimSso, "/api/auth/claim/sso", { token: TOKEN, username: "alex" }], ] as const)( "POST %s — refusals that read no token", - (_n, route, path, body) => { - it("refuses on a single-sign-on-only instance before anything else", async () => { - vi.mocked(isOidcOnly).mockReturnValue(true); - vi.mocked(getSession).mockResolvedValue({ user: { id: "u1" } } as never); - const res = await route(post(path, body)); - expect(res.status).toBe(403); - expect(await errorCode(res)).toBe("profile_claim.oidc_only_unsupported"); - expect(previewHandover).not.toHaveBeenCalled(); + (name, route, path, body) => { + beforeEach(() => { + if (name === "sso") { + vi.mocked(isOidcConfigured).mockReturnValue(true); + vi.mocked(getOidcConfig).mockReturnValue(OIDC_CONFIG); + } + if (name === "sso") { + vi.mocked(usableHandoverForToken).mockImplementation(async () => { + // Routes the sso start through the same "link" spy the other two + // use, so one set of assertions covers all three. + const found = await previewHandover(TOKEN); + return found ? { handoverId: "h1", profileId: "p1" } : null; + }); + } }); it("refuses over a live session before spending the rate limit", async () => { @@ -234,7 +302,7 @@ describe("POST /api/auth/claim", () => { expect(res.status).toBe(201); expect(claimManagedProfile).toHaveBeenCalledWith( expect.objectContaining({ - rawToken: TOKEN, + link: { rawToken: TOKEN }, username: "alex", email: "alex@example.test", passwordHash: "argon2-hash", @@ -260,7 +328,101 @@ describe("POST /api/auth/claim/preview", () => { "displayName", "expiresAt", "guardians", + "methods", + "ssoProviderName", ]); + expect(body.data.methods).toEqual(["password"]); + expect(body.data.ssoProviderName).toBeNull(); expect(body.data.expiresAt).toBe("2030-01-01T00:00:00.000Z"); }); }); + +describe("POST /api/auth/claim/sso", () => { + const SSO_BODY = { token: TOKEN, username: "alex" }; + + beforeEach(() => { + vi.mocked(isOidcConfigured).mockReturnValue(true); + vi.mocked(getOidcConfig).mockReturnValue(OIDC_CONFIG); + }); + + it("answers 404 sso_unavailable without an identity provider, before anything else", async () => { + vi.mocked(isOidcConfigured).mockReturnValue(false); + vi.mocked(getSession).mockResolvedValue({ user: { id: "u1" } } as never); + const res = await claimSso(post("/api/auth/claim/sso", SSO_BODY)); + expect(res.status).toBe(404); + expect(await errorCode(res)).toBe("profile_claim.sso_unavailable"); + expect(checkAuthSurfaceRateLimit).not.toHaveBeenCalled(); + expect(usableHandoverForToken).not.toHaveBeenCalled(); + }); + + it("works on a single-sign-on-only instance", async () => { + singleSignOnOnly(); + const res = await claimSso(post("/api/auth/claim/sso", SSO_BODY)); + expect(res.status).toBe(200); + }); + + it("draws on the password claim's bucket", async () => { + await claimSso(post("/api/auth/claim/sso", SSO_BODY)); + expect(checkAuthSurfaceRateLimit).toHaveBeenCalledWith( + expect.anything(), + "auth:claim", + 5, + 15 * 60 * 1000, + ); + }); + + it("checks the link before the username probe", async () => { + vi.mocked(usableHandoverForToken).mockResolvedValue(null); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: "x" } as never); + const res = await claimSso(post("/api/auth/claim/sso", SSO_BODY)); + expect(res.status).toBe(404); + expect(await errorCode(res)).toBe("profile_claim.invalid"); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + expect(res.headers.get("set-cookie")).toBeNull(); + }); + + it("answers 409 taken for a username in use, and sets no cookie", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: "x" } as never); + const res = await claimSso(post("/api/auth/claim/sso", SSO_BODY)); + expect(res.status).toBe(409); + expect(await errorCode(res)).toBe("profile_claim.taken"); + expect(res.headers.get("set-cookie")).toBeNull(); + }); + + it("refuses an unknown field and a managed- username (strict body)", async () => { + for (const body of [ + { ...SSO_BODY, email: "x@example.test" }, + { ...SSO_BODY, username: "managed-alex" }, + ]) { + const res = await claimSso(post("/api/auth/claim/sso", body)); + expect(res.status).toBe(422); + } + expect(usableHandoverForToken).not.toHaveBeenCalled(); + }); + + it("seals the link's id and the username, never the token, into hl_claim", async () => { + const res = await claimSso(post("/api/auth/claim/sso", SSO_BODY)); + expect(res.status).toBe(200); + expect((await res.json()).data).toEqual({ + redirectTo: "/api/auth/oidc/login?intent=claim", + }); + const cookie = res.headers.get("set-cookie") ?? ""; + expect(cookie).toMatch(/^hl_claim=/); + expect(cookie).toMatch(/Path=\/api\/auth\/oidc/); + expect(cookie).toMatch(/HttpOnly/i); + expect(cookie).toMatch(/Secure/i); + expect(cookie).toMatch(/SameSite=lax/i); + expect(cookie).toMatch(/Max-Age=600/); + const sealed = decodeURIComponent(cookie.split(";")[0]!.slice(9)); + const payload = JSON.parse(sealed.replace(/^sealed\(/, "").slice(0, -1)); + expect(payload.h).toBe("h1"); + expect(payload.u).toBe("alex"); + expect(typeof payload.b).toBe("string"); + expect(cookie).not.toContain(TOKEN); + expect(JSON.stringify(vi.mocked(auditLog).mock.calls)).not.toContain(TOKEN); + expect(auditLog).toHaveBeenCalledWith( + "managed_profile.claim_sso.started", + expect.objectContaining({ userId: "p1", details: { handoverId: "h1" } }), + ); + }); +}); diff --git a/src/app/api/auth/claim/preview/route.ts b/src/app/api/auth/claim/preview/route.ts index 865f7fdfb..b5cb0b924 100644 --- a/src/app/api/auth/claim/preview/route.ts +++ b/src/app/api/auth/claim/preview/route.ts @@ -4,6 +4,8 @@ * access (v1.42, #959). No health data. * * Anonymous by design: the person opening the link has no credentials yet. + * Answered on a single-sign-on-only server too (v1.43): `methods` then names + * only `sso`, and the page offers the identity provider. * The one-time `hlp_` token travels in the BODY, never in this request's URL, * so it never reaches an access log or a wide event's `http.path`. Every * unusable link — malformed, unknown, expired, used, withdrawn, a profile no @@ -15,7 +17,12 @@ import { apiHandler } from "@/lib/api-handler"; import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; import { getSession } from "@/lib/auth/session"; import { annotate } from "@/lib/logging/context"; -import { claimGate, claimInvalid } from "@/lib/managed-profiles/claim-gate"; +import { + claimGate, + claimInvalid, + handoverMethods, + ssoProviderName, +} from "@/lib/managed-profiles/claim-gate"; import { previewHandover } from "@/lib/managed-profiles/handover"; import { claimPreviewSchema } from "@/lib/validations/managed-profile-handover"; @@ -46,5 +53,9 @@ export const POST = apiHandler(async (request: NextRequest) => { displayName: preview.displayName, expiresAt: preview.expiresAt.toISOString(), guardians: preview.guardians, + // v1.43 (#959) — which ways in this server offers, so the page shows the + // password form, the provider button, or both. + methods: handoverMethods(), + ssoProviderName: ssoProviderName(), }); }); diff --git a/src/app/api/auth/claim/route.ts b/src/app/api/auth/claim/route.ts index 82bba356d..9ca4e09c2 100644 --- a/src/app/api/auth/claim/route.ts +++ b/src/app/api/auth/claim/route.ts @@ -104,7 +104,7 @@ export const POST = apiHandler(async (request: NextRequest) => { let claimed; try { claimed = await claimManagedProfile({ - rawToken: token, + link: { rawToken: token }, username, email, passwordHash, diff --git a/src/app/api/auth/claim/sso/route.ts b/src/app/api/auth/claim/sso/route.ts index 01b591758..21fd4938b 100644 --- a/src/app/api/auth/claim/sso/route.ts +++ b/src/app/api/auth/claim/sso/route.ts @@ -1,29 +1,47 @@ /** * `POST /api/auth/claim/sso` — start claiming a managed profile through - * single sign-on (#959). + * single sign-on (v1.43, #959). * - * Anonymous, like the password claim beside it. `{ token, username }`: the - * link is checked and the username reserved for the round trip, a sealed - * `hl_claim` cookie (10 minutes, `httpOnly`, `Secure` through - * `shouldEmitSecureCookie()`) carries `{ handoverId, username, exp }`, and the - * answer names where to go: `{ redirectTo: "/api/auth/oidc/login?intent=claim" }`. - * The OIDC callback binds the identity provider's subject to the profile. The - * token never rides a URL. Works on a single-sign-on-only server; 404 - * `profile_claim.sso_unavailable` when no identity provider is configured. + * Anonymous, like the password claim beside it, and ordered like it: + * + * 1. refusals that read no token (`claimGate`: no identity provider, a live + * session, the per-address bucket the password claim also draws on); + * 2. the body, strictly parsed, and the link checked; + * 3. the username probe, so the person learns a name is taken here rather + * than after the round trip to the provider. + * + * Nothing about the account changes here. The answer is a sealed `hl_claim` + * cookie (`claim-cookie.ts`) naming the link by its id and carrying the + * chosen username, and `{ redirectTo }` for the browser to navigate to. The + * OIDC callback binds the provider's subject to the profile, under the lock, + * with every check repeated. The token never rides a URL and never reaches the + * cookie. */ import { NextRequest } from "next/server"; import { apiHandler } from "@/lib/api-handler"; -import { returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; import { getSession } from "@/lib/auth/session"; -import { notImplemented } from "@/lib/http/not-implemented"; -import { claimGate } from "@/lib/managed-profiles/claim-gate"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { + CLAIM_SSO_REDIRECT, + setClaimCookie, +} from "@/lib/managed-profiles/claim-cookie"; +import { claimGate, claimInvalid } from "@/lib/managed-profiles/claim-gate"; +import { usableHandoverForToken } from "@/lib/managed-profiles/handover"; import { claimSsoSchema } from "@/lib/validations/claim-sso"; export const POST = apiHandler(async (request: NextRequest) => { // Refuses a signed-in caller and rate-limits the address, as the password // claim does. - const gate = await claimGate(request, "claim", (await getSession()) !== null); + const gate = await claimGate(request, "sso", (await getSession()) !== null); if (gate.refusal) return gate.refusal; const { data: body, error: jsonError } = await safeJson(request, { @@ -32,6 +50,43 @@ export const POST = apiHandler(async (request: NextRequest) => { if (jsonError) return jsonError; const parsed = claimSsoSchema.safeParse(body); if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + const { token, username } = parsed.data; - return notImplemented("profile_claim.sso.start"); + // The link first, so the username probe below is no oracle for anybody + // without a live link (the password claim's argument). + const link = await usableHandoverForToken(token); + if (!link) { + annotate({ + action: { name: "profile_claim.sso.start" }, + meta: { found: false }, + }); + return claimInvalid(); + } + + const existing = await prisma.user.findUnique({ + where: { username }, + select: { id: true }, + }); + if (existing) { + return apiError("Username already taken", 409, { + errorCode: "profile_claim.taken", + }); + } + + const response = apiSuccess({ redirectTo: CLAIM_SSO_REDIRECT }); + setClaimCookie(response, { handoverId: link.handoverId, username }); + + // Filed under the record, with no actor: nobody is signed in yet. No token, + // no username — the username is not the person's until the claim lands. + await auditLog("managed_profile.claim_sso.started", { + userId: link.profileId, + actorUserId: null, + ipAddress: gate.ip, + details: { handoverId: link.handoverId }, + }); + annotate({ + action: { name: "profile_claim.sso.start" }, + meta: { found: true }, + }); + return response; }); diff --git a/src/app/api/auth/oidc/callback/route.ts b/src/app/api/auth/oidc/callback/route.ts index 0c090f4a0..f5d155a55 100644 --- a/src/app/api/auth/oidc/callback/route.ts +++ b/src/app/api/auth/oidc/callback/route.ts @@ -38,6 +38,17 @@ import { mintNativeHandoff, } from "@/lib/auth/oidc-native-handoff"; import { setPendingLinkCookie } from "@/lib/auth/oidc-pending-link"; +import { + CLAIM_COOKIE, + CLAIM_ERROR_PATH, + claimMatchesBinding, + clearClaimCookie, + readClaimCookie, +} from "@/lib/managed-profiles/claim-cookie"; +import { + completeSsoClaim, + SSO_CLAIM_LANDING, +} from "@/lib/managed-profiles/claim-sso"; const LOGIN_ERROR_URL = "/auth/login"; @@ -102,6 +113,14 @@ interface StoredOidcState { native?: boolean; /** The app's S256 PKCE challenge, bound at login; verified at the exchange. */ appCodeChallenge?: string; + /** + * v1.43 (#959) — set only when the login carried `intent=claim` and a valid + * `hl_claim` cookie. The callback then claims the managed profile the + * cookie names instead of signing in, and only when the cookie still + * carries this exact binding. Web only: never together with `native`. + */ + intent?: "claim"; + claimBinding?: string; } function isStoredOidcState(value: unknown): value is StoredOidcState { @@ -122,6 +141,13 @@ function isStoredOidcState(value: unknown): value is StoredOidcState { if (v.native !== true) return false; if (typeof v.appCodeChallenge !== "string") return false; } + if (v.intent !== undefined) { + if (v.intent !== "claim") return false; + if (typeof v.claimBinding !== "string" || v.claimBinding.length === 0) { + return false; + } + if (v.native !== undefined) return false; + } return true; } @@ -153,12 +179,24 @@ export const GET = apiHandler(async (req: NextRequest) => { } } const isNative = stored?.native === true; + const isClaim = stored?.intent === "claim"; // Native-aware error redirect: the custom scheme for a decrypted native flow, // the web login page otherwise. Deletes the single-use state cookie either // way. An error redirect carries no code, ticket, or session, so routing it // to the scheme leaks nothing (spec §1). const failRedirect = (reason: string): NextResponse => { + if (isClaim) { + // A refused claim returns to the claim page, never the sign-in page, + // and takes the claim cookie with it: the person starts again from the + // link, which is still theirs to use if the refusal left it open. + const response = NextResponse.redirect( + oidcAppUrl(`${CLAIM_ERROR_PATH}?error=${reason}`), + ); + deleteStateCookie(response); + clearClaimCookie(response); + return response; + } if (isNative) { const response = NextResponse.redirect( buildNativeCallbackUrl({ error: reason }), @@ -198,7 +236,9 @@ export const GET = apiHandler(async (req: NextRequest) => { // Broken round-trip / missing-or-unreadable state blob / CSRF mismatch all // resolve to the web `oidc_state` fallback: the flow is fundamentally broken // and there is nothing safe to hand the app (spec §1 web-fallback). - if (!code || !state) return errorRedirect("oidc_state"); + if (!code || !state) { + return isClaim ? failRedirect("oidc_state") : errorRedirect("oidc_state"); + } if (!stored) { // Defence-in-depth idempotency for a duplicated callback. The single-use @@ -240,7 +280,32 @@ export const GET = apiHandler(async (req: NextRequest) => { !timingSafeEqual(Buffer.from(state), Buffer.from(stored.state)) ) { annotate({ meta: { reason: "csrf" } }); - return errorRedirect("oidc_state"); + return isClaim ? failRedirect("oidc_state") : errorRedirect("oidc_state"); + } + + // v1.43 (#959) — a claim is admitted only with its own sealed cookie, still + // bound to this sign-in, and never over a live session (the claim routes' + // rule: a Guardian finishing their own link would otherwise be signed out + // of their account and into the person's). Both before the code is + // redeemed, so a refused claim spends nothing at the provider. + const pendingClaim = isClaim + ? readClaimCookie(req.cookies.get(CLAIM_COOKIE)?.value) + : null; + if (isClaim) { + if ( + !pendingClaim || + !claimMatchesBinding(pendingClaim, stored.claimBinding as string) + ) { + annotate({ meta: { reason: "claim_cookie" } }); + return failRedirect("claim_expired"); + } + const liveSession = await validateSessionFromCookieValue( + req.cookies.get(SESSION_COOKIE_NAME)?.value, + ); + if (liveSession) { + annotate({ meta: { reason: "claim_signed_in" } }); + return failRedirect("claim_signed_in"); + } } try { @@ -274,6 +339,28 @@ export const GET = apiHandler(async (req: NextRequest) => { emailVerified = userinfo.emailVerified; } + // v1.43 (#959) — the claim. The provider has authenticated somebody; bind + // that subject to the profile the cookie names, or refuse. Never falls + // through to the sign-in below: a refused claim signs nobody in, and + // provisions and links nothing. + if (pendingClaim) { + const outcome = await completeSsoClaim({ + handoverId: pendingClaim.handoverId, + username: pendingClaim.username, + issuer: metadata.issuer, + sub: identity.sub, + email, + emailVerified, + ipAddress: ip, + userAgent: req.headers.get("user-agent"), + }); + if (!outcome.ok) return failRedirect(outcome.reason); + const response = NextResponse.redirect(oidcAppUrl(SSO_CLAIM_LANDING)); + deleteStateCookie(response); + clearClaimCookie(response); + return response; + } + // (1) Durable identity match — the (issuer, sub) pair stamped on a // previous login, link, or provision. Email is deliberately NOT // consulted here: after the stamp it is a display field, so an diff --git a/src/app/api/auth/oidc/login/__tests__/route.test.ts b/src/app/api/auth/oidc/login/__tests__/route.test.ts index 38c13a58f..1d9fc1584 100644 --- a/src/app/api/auth/oidc/login/__tests__/route.test.ts +++ b/src/app/api/auth/oidc/login/__tests__/route.test.ts @@ -214,3 +214,50 @@ describe("GET /api/auth/oidc/login", () => { }); }); }); + +describe("GET /api/auth/oidc/login?intent=claim (v1.43, #959)", () => { + beforeEach(() => { + vi.mocked(getOidcConfig).mockReturnValue({ + issuerUrl: "https://idp.example.com", + clientId: "client-1", + clientSecret: "secret-1", + scopes: "openid email profile", + buttonLabel: "SSO", + }); + }); + + it("refuses a claim on the native leg with oidc_invalid_request", async () => { + const res = await GET( + makeRequest( + `/api/auth/oidc/login?intent=claim&client=native&code_challenge=${"a".repeat(43)}`, + ), + ); + expect(res.headers.get("location")).toMatch( + /^healthlog:\/\/oidc-callback\?error=oidc_invalid_request$/, + ); + expect(discoverOidcMetadata).not.toHaveBeenCalled(); + expect(res.cookies.get("oidc_auth_state")).toBeUndefined(); + }); + + it("sends a claim without its cookie back to the claim page, before the provider", async () => { + const res = await GET(makeRequest("/api/auth/oidc/login?intent=claim")); + expect(res.headers.get("location")).toBe( + "https://healthlog.example.com/auth/claim?error=claim_expired", + ); + expect(discoverOidcMetadata).not.toHaveBeenCalled(); + expect(res.cookies.get("oidc_auth_state")).toBeUndefined(); + }); + + it("routes the claim's other failures to the claim page too", async () => { + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + reset: 0, + ip: "1.2.3.4", + } as never); + const res = await GET(makeRequest("/api/auth/oidc/login?intent=claim")); + expect(res.headers.get("location")).toBe( + "https://healthlog.example.com/auth/claim?error=oidc_rate_limited", + ); + }); +}); diff --git a/src/app/api/auth/oidc/login/route.ts b/src/app/api/auth/oidc/login/route.ts index 9e55d9c0a..dd0dc1cc8 100644 --- a/src/app/api/auth/oidc/login/route.ts +++ b/src/app/api/auth/oidc/login/route.ts @@ -21,6 +21,12 @@ import { OIDC_STATE_COOKIE_PATH, OIDC_STATE_TTL_MS, } from "@/lib/auth/oidc-cookie"; +import { + CLAIM_COOKIE, + CLAIM_ERROR_PATH, + readClaimCookie, +} from "@/lib/managed-profiles/claim-cookie"; +import { isHandoverUsable } from "@/lib/managed-profiles/handover"; /** * Redirects the browser to the configured OIDC provider's authorization @@ -40,16 +46,34 @@ export const GET = apiHandler(async (req: NextRequest) => { // nothing — an error redirect carries no code, ticket, or session (spec §1). const isNative = req.nextUrl.searchParams.get("client") === "native"; const appCodeChallenge = req.nextUrl.searchParams.get("code_challenge"); + // v1.43 (#959) — `intent=claim`: the sign-in claims a managed profile. The + // link and the username ride the sealed `hl_claim` cookie set by + // `POST /api/auth/claim/sso`, never this URL; the intent only says to look + // for it. Web only. + const isClaim = req.nextUrl.searchParams.get("intent") === "claim"; // Native-aware error redirect: the scheme for a native start, the web login - // page otherwise. Machine-readable `error=` in both. + // page otherwise, and the claim page for a claim. Machine-readable + // `error=` in all three. const loginError = (reason: string): NextResponse => { if (isNative) { return NextResponse.redirect(buildNativeCallbackUrl({ error: reason })); } + if (isClaim) { + return NextResponse.redirect( + oidcAppUrl(`${CLAIM_ERROR_PATH}?error=${reason}`), + ); + } return NextResponse.redirect(oidcAppUrl(`/auth/login?error=${reason}`)); }; + // The app never claims a profile: the claim runs in a browser, and the new + // owner signs in to the app afterwards through the ordinary native leg. + if (isClaim && isNative) { + annotate({ meta: { reason: "claim_on_native" } }); + return loginError("oidc_invalid_request"); + } + const config = getOidcConfig(); if (!config) { return loginError("oidc_disabled"); @@ -90,6 +114,19 @@ export const GET = apiHandler(async (req: NextRequest) => { return loginError("oidc_invalid_request"); } + // A claim needs its sealed cookie and a link that is still usable. Checked + // here, after the rate limit, so a dead link is told before the round trip + // to the provider; the callback checks both again. + let claimBinding: string | null = null; + if (isClaim) { + const claim = readClaimCookie(req.cookies.get(CLAIM_COOKIE)?.value); + if (!claim || !(await isHandoverUsable(claim.handoverId))) { + annotate({ meta: { reason: "claim_expired" } }); + return loginError("claim_expired"); + } + claimBinding = claim.binding; + } + let metadata; try { metadata = await discoverOidcMetadata(config); @@ -104,10 +141,12 @@ export const GET = apiHandler(async (req: NextRequest) => { const codeChallenge = s256Challenge(codeVerifier); // `next` is a post-login WEB path, meaningless to the app — pin it to "/" on - // the native branch (one less input to sanitize). - const safeNext = isNative - ? "/" - : sanitizeOidcNextPath(req.nextUrl.searchParams.get("next"), req.url); + // the native branch (one less input to sanitize). A claim lands on a fixed + // page the callback names, so its `next` is pinned too. + const safeNext = + isNative || isClaim + ? "/" + : sanitizeOidcNextPath(req.nextUrl.searchParams.get("next"), req.url); const redirectUri = getOidcRedirectUri(); const authorizationUrl = buildAuthorizationUrl({ @@ -133,6 +172,12 @@ export const GET = apiHandler(async (req: NextRequest) => { ...(isNative ? { native: true as const, appCodeChallenge: appCodeChallenge! } : {}), + // The claim's binding, inside the same tamper-authenticated blob: the + // callback admits the claim only when the cookie it carries back holds + // this exact value. + ...(claimBinding !== null + ? { intent: "claim" as const, claimBinding } + : {}), }), ); response.cookies.set(OIDC_STATE_COOKIE, cookiePayload, { diff --git a/src/app/api/managed-profiles/[id]/handover/route.ts b/src/app/api/managed-profiles/[id]/handover/route.ts index 3c702f200..1177988b2 100644 --- a/src/app/api/managed-profiles/[id]/handover/route.ts +++ b/src/app/api/managed-profiles/[id]/handover/route.ts @@ -30,8 +30,8 @@ import { safeJson, } from "@/lib/api-response"; import { buildHandoverUrl } from "@/lib/auth/handover-token"; -import { isOidcOnly } from "@/lib/auth/oidc"; import { annotate } from "@/lib/logging/context"; +import { handoverMethods } from "@/lib/managed-profiles/claim-gate"; import { createHandover, HandoverError, @@ -49,16 +49,15 @@ const notFound = () => }); /** - * v1.42 refuses the handover on a single-sign-on-only instance: the claim - * sets a password, and such an instance admits none. The SSO arm of the claim - * is a separate piece of work. + * A link nobody could redeem is not minted. Since v1.43 the person can claim + * through the identity provider, so a single-sign-on-only server hands over + * too; this answers only when neither a password nor single sign-on is + * possible, which a working configuration never produces. */ -const oidcOnlyRefusal = () => - apiError( - "Handing over a profile is not available while this server allows single sign-on only", - 403, - { errorCode: "profile_claim.oidc_only_unsupported" }, - ); +const noMethodRefusal = () => + apiError("Handing over a profile is not available on this server", 403, { + errorCode: "profile_claim.oidc_only_unsupported", + }); /** Same ceiling as creating, editing and inviting: ten an hour per caller. */ const CREATE_LIMIT = 10; @@ -79,8 +78,10 @@ export const GET = apiHandler( action: { name: "managed_profile.handover.read" }, meta: { profile_id: id, open: status.open !== null }, }); + const methods = handoverMethods(); return apiSuccess({ - available: !isOidcOnly(), + available: methods.length > 0, + methods, open: status.open ? { id: status.open.id, @@ -107,7 +108,7 @@ export const POST = apiHandler( if (!rateLimit.allowed) { return apiError("Too many handover links, try again later", 429); } - if (isOidcOnly()) return oidcOnlyRefusal(); + if (handoverMethods().length === 0) return noMethodRefusal(); const { id } = await params; const { data: body, error: jsonError } = await safeJson(request, { diff --git a/src/lib/managed-profiles/claim-cookie.ts b/src/lib/managed-profiles/claim-cookie.ts new file mode 100644 index 000000000..f99e05b55 --- /dev/null +++ b/src/lib/managed-profiles/claim-cookie.ts @@ -0,0 +1,111 @@ +/** + * v1.43 (#959) — the sealed cookie that carries a single-sign-on claim through + * the round trip to the identity provider. + * + * `POST /api/auth/claim/sso` checks the handover token and the chosen username + * and seals `{ handoverId, username, binding, exp }` into `hl_claim`. The + * token itself never leaves that request: the cookie names the link by its + * id, and it can only have been minted by a request that held the token. + * + * - AES-256-GCM with the at-rest key (`encrypt`), so it can be neither read + * nor forged, and it carries its own expiry on top of `Max-Age`. + * - `HttpOnly`, `SameSite=Lax` (the browser returns from the provider on a + * cross-site top-level GET and must carry it), `Secure` through + * `shouldEmitSecureCookie()`. + * - Scoped to `/api/auth/oidc`, the only two routes that read it. + * - `binding` is a fresh random value per start. The login copies it into + * the encrypted OIDC state, and the callback admits the claim only when + * the cookie and the state carry the same one, so a claim cookie from a + * different start (another tab, an older attempt) cannot be paired with + * this sign-in. + */ +import { randomBytes, timingSafeEqual } from "node:crypto"; +import type { NextResponse } from "next/server"; + +import { decrypt, encrypt } from "@/lib/crypto"; +import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; + +export const CLAIM_COOKIE = "hl_claim"; +export const CLAIM_COOKIE_PATH = "/api/auth/oidc"; +export const CLAIM_COOKIE_TTL_MS = 10 * 60 * 1000; + +/** + * Where `POST /api/auth/claim/sso` sends the browser next. A fixed path: + * nothing caller-supplied ever reaches a redirect on this flow. + */ +export const CLAIM_SSO_REDIRECT = "/api/auth/oidc/login?intent=claim"; + +/** Where every refusal on the single-sign-on claim lands, with `?error=`. */ +export const CLAIM_ERROR_PATH = "/auth/claim"; + +export interface PendingSsoClaim { + handoverId: string; + username: string; + binding: string; +} + +/** Attach a freshly sealed claim to a response. Returns what was sealed. */ +export function setClaimCookie( + response: NextResponse, + claim: { handoverId: string; username: string }, + now: number = Date.now(), +): PendingSsoClaim { + const binding = randomBytes(32).toString("base64url"); + const sealed = encrypt( + JSON.stringify({ + h: claim.handoverId, + u: claim.username, + b: binding, + e: now + CLAIM_COOKIE_TTL_MS, + }), + ); + response.cookies.set(CLAIM_COOKIE, sealed, { + httpOnly: true, + secure: shouldEmitSecureCookie(), + sameSite: "lax", + maxAge: Math.floor(CLAIM_COOKIE_TTL_MS / 1000), + path: CLAIM_COOKIE_PATH, + }); + return { ...claim, binding }; +} + +/** The claim a cookie value carries, or null when absent, forged or expired. */ +export function readClaimCookie( + raw: string | undefined, + now: number = Date.now(), +): PendingSsoClaim | null { + if (!raw) return null; + try { + const parsed = JSON.parse(decrypt(raw)) as Record; + if ( + typeof parsed.h !== "string" || + parsed.h.length === 0 || + typeof parsed.u !== "string" || + parsed.u.length === 0 || + typeof parsed.b !== "string" || + parsed.b.length === 0 || + typeof parsed.e !== "number" || + parsed.e <= now + ) { + return null; + } + return { handoverId: parsed.h, username: parsed.u, binding: parsed.b }; + } catch { + return null; + } +} + +/** Whether a claim belongs to the sign-in whose state carries `binding`. */ +export function claimMatchesBinding( + claim: PendingSsoClaim, + binding: string, +): boolean { + const a = Buffer.from(claim.binding); + const b = Buffer.from(binding); + return a.length === b.length && timingSafeEqual(a, b); +} + +/** Expire the claim cookie on a response; the path must repeat the set's. */ +export function clearClaimCookie(response: NextResponse): void { + response.cookies.delete({ name: CLAIM_COOKIE, path: CLAIM_COOKIE_PATH }); +} diff --git a/src/lib/managed-profiles/claim-gate.ts b/src/lib/managed-profiles/claim-gate.ts index 3c1a4ec47..2173fd6e0 100644 --- a/src/lib/managed-profiles/claim-gate.ts +++ b/src/lib/managed-profiles/claim-gate.ts @@ -1,11 +1,14 @@ /** - * v1.42 (#959) — the refusals both anonymous claim routes give before they - * look at a token, in one place so the preview and the claim cannot disagree. + * v1.42 (#959) — the refusals the anonymous claim routes give before they look + * at a token, in one place so the preview and the claims cannot disagree. * * Order matters and is the register route's order: * - * 1. A single-sign-on-only instance admits no password, and the claim sets - * one, so the handover is refused outright in v1.42. + * 1. What this server cannot do at all. The password claim sets a password, + * so a single-sign-on-only instance refuses it outright; the + * single-sign-on claim (v1.43) needs an identity provider, and answers + * 404 `profile_claim.sso_unavailable` without one. The preview is + * refused by neither: it is what tells the page which way is open. * 2. A signed-in caller is refused with 409. Claiming creates a session for * the new owner, and a Guardian testing their own link would otherwise be * signed out of their account and into the person's. Refused before the @@ -13,12 +16,14 @@ * would lock out the person the link is for, who may share the address. * 3. The per-address rate limit, the anonymous auth-surface pattern: a * misconfigured proxy trust chain collapses every caller into one tight - * bucket rather than one free-for-all. + * bucket rather than one free-for-all. Both claims share one bucket, so + * switching between them buys no extra attempts. */ import { NextResponse } from "next/server"; import { apiError } from "@/lib/api-response"; -import { isOidcOnly } from "@/lib/auth/oidc"; +import { getOidcConfig, isOidcConfigured, isOidcOnly } from "@/lib/auth/oidc"; +import type { HandoverMethod } from "@/lib/validations/claim-sso"; import { annotate } from "@/lib/logging/context"; import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -29,9 +34,28 @@ export function claimInvalid() { }); } +/** + * v1.43 (#959) — how a handover link can be redeemed on this server: with a + * password unless the server allows single sign-on only, and through the + * identity provider whenever one is configured. Never empty in practice + * (`OIDC_ONLY` only takes effect with a configured provider), but the Guardian + * side refuses on an empty list rather than assuming it. + */ +export function handoverMethods(): HandoverMethod[] { + const methods: HandoverMethod[] = []; + if (!isOidcOnly()) methods.push("password"); + if (isOidcConfigured()) methods.push("sso"); + return methods; +} + +/** The provider's name for the "Continue with …" button, or null. */ +export function ssoProviderName(): string | null { + return getOidcConfig()?.buttonLabel ?? null; +} + export async function claimGate( request: Request, - surface: "preview" | "claim", + surface: "preview" | "claim" | "sso", /** * Whether the request carries a live session. Resolved by the ROUTE with * `getSession()` and handed in, rather than resolved here: a session read @@ -41,15 +65,24 @@ export async function claimGate( */ signedIn: boolean, ): Promise<{ refusal: Response } | { refusal: null; ip: string | null }> { - if (isOidcOnly()) { + if (surface === "claim" && isOidcOnly()) { return { refusal: apiError( - "Taking over a profile is not available while this server allows single sign-on only", + "This server allows single sign-on only; take the profile over through the identity provider", 403, { errorCode: "profile_claim.oidc_only_unsupported" }, ), }; } + if (surface === "sso" && !isOidcConfigured()) { + return { + refusal: apiError( + "Single sign-on is not configured on this server", + 404, + { errorCode: "profile_claim.sso_unavailable" }, + ), + }; + } if (signedIn) { annotate({ diff --git a/src/lib/managed-profiles/claim-sso.ts b/src/lib/managed-profiles/claim-sso.ts new file mode 100644 index 000000000..6fbf8c843 --- /dev/null +++ b/src/lib/managed-profiles/claim-sso.ts @@ -0,0 +1,178 @@ +/** + * v1.43 (#959) — the callback's half of claiming a managed profile through + * single sign-on: the identity provider has authenticated somebody, the + * sealed claim cookie names the link and the chosen username, and this binds + * the one to the other. + * + * The refusals, in order, each one before anything about any account changes: + * + * 1. `claim_expired` — the link is no longer usable (used, withdrawn, + * replaced, expired, its creator gone, the profile no longer managed). + * 2. `claim_sso_subject_bound` — the provider's `(issuer, sub)` already + * signs in to an account here. One subject, one account: binding it a + * second time would make the sign-in ambiguous, and re-pointing it would + * hand the existing account's sign-in to the claimed profile. + * 3. `claim_email_taken` — the provider vouches (`email_verified: true`) for + * an address another account holds. An unverified address is not used at + * all: the account is created without one rather than with a claim + * nobody checked. + * 4. `claim_username_taken` — the username chosen at the start was taken in + * the ten minutes since. + * + * Then `claimManagedProfile` — the password claim's own transaction, under + * the managed-profile lock, with every link check repeated — sets the + * username, the address, no password and the subject; applies each Guardian's + * proposed access; resets disclaimer, onboarding and AI consent. A race lost + * at the unique indexes is re-read and answered as the refusal it turned out + * to be. The session is minted only after that commit. + */ +import { auditLog } from "@/lib/auth/audit"; +import { recordSignInDevice } from "@/lib/auth/login-alert"; +import { createSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { + claimManagedProfile, + HandoverError, + isHandoverUsable, +} from "@/lib/managed-profiles/handover"; +import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify"; + +export type SsoClaimRefusal = + | "claim_expired" + | "claim_sso_subject_bound" + | "claim_email_taken" + | "claim_username_taken"; + +export type SsoClaimOutcome = + { ok: true; profileId: string } | { ok: false; reason: SsoClaimRefusal }; + +/** Where the new owner lands: the decision about each former Guardian. */ +export const SSO_CLAIM_LANDING = "/onboarding/handover"; + +interface SsoClaimInput { + handoverId: string; + username: string; + issuer: string; + sub: string; + email: string | null; + emailVerified: boolean | undefined; + ipAddress: string; + userAgent: string | null; +} + +/** Which of the three unique values is held by an account already. */ +async function collision(input: { + issuer: string; + sub: string; + email: string | null; + username: string; +}): Promise { + const bound = await prisma.user.findFirst({ + where: { oidcIssuer: input.issuer, oidcSub: input.sub }, + select: { id: true }, + }); + if (bound) { + // Under the account that holds the subject, so its owner's history shows + // that somebody tried to bind their sign-in to a second record. + await auditLog("auth.oidc.claim_conflict", { + userId: bound.id, + actorUserId: null, + }); + return "claim_sso_subject_bound"; + } + if (input.email) { + const byEmail = await prisma.user.findFirst({ + where: { email: { equals: input.email, mode: "insensitive" } }, + select: { id: true }, + }); + if (byEmail) return "claim_email_taken"; + } + const byName = await prisma.user.findUnique({ + where: { username: input.username }, + select: { id: true }, + }); + return byName ? "claim_username_taken" : null; +} + +export async function completeSsoClaim( + input: SsoClaimInput, +): Promise { + const refuse = (reason: SsoClaimRefusal): SsoClaimOutcome => { + annotate({ + action: { name: "profile_claim.sso.refused" }, + meta: { reason }, + }); + return { ok: false, reason }; + }; + + if (!(await isHandoverUsable(input.handoverId))) { + return refuse("claim_expired"); + } + + const email = + input.email && input.emailVerified === true + ? input.email.toLowerCase() + : null; + const identity = { + issuer: input.issuer, + sub: input.sub, + email, + username: input.username, + }; + + const early = await collision(identity); + if (early) return refuse(early); + + let claimed; + try { + claimed = await claimManagedProfile({ + link: { handoverId: input.handoverId }, + username: input.username, + email, + passwordHash: null, + oidc: { issuer: input.issuer, sub: input.sub }, + ipAddress: input.ipAddress, + }); + } catch (error) { + if (error instanceof HandoverError) { + if (error.code === "taken") { + // Lost a race at a unique index: say which one. + return refuse((await collision(identity)) ?? "claim_username_taken"); + } + return refuse("claim_expired"); + } + throw error; + } + + // The new owner starts with onboarding owed, as after the password claim. + // The fifth argument stays null: single sign-on is a delegated factor and + // must never satisfy a step-up (the OIDC login's rule). + await createSession( + claimed.profileId, + true, + input.ipAddress, + input.userAgent, + null, + ); + void recordSignInDevice({ + userId: claimed.profileId, + ip: input.ipAddress, + userAgent: input.userAgent, + alertOnNew: false, + }); + await auditLog("auth.claim", { + userId: claimed.profileId, + actorUserId: null, + ipAddress: input.ipAddress, + details: { method: "sso" }, + }); + + notifyGuardiansOfHandover("claimed", claimed.displayName, claimed.guardians); + + annotate({ + action: { name: "profile_claim.complete" }, + meta: { guardians: claimed.guardians.length, method: "sso" }, + }); + return { ok: true, profileId: claimed.profileId }; +} diff --git a/src/lib/managed-profiles/handover.ts b/src/lib/managed-profiles/handover.ts index 431bd30d8..b09ce6596 100644 --- a/src/lib/managed-profiles/handover.ts +++ b/src/lib/managed-profiles/handover.ts @@ -444,17 +444,19 @@ export interface HandoverPreview { } /** - * What a link would hand over, or null for every kind of unusable link. - * - * Deliberately minimal: the profile's name, the expiry, and who keeps which - * access. No health data, no date of birth, no usernames and no grant ids — - * the person holding the link has not proved anything yet beyond holding it. + * The open link behind a stored row, or null when it can no longer be + * claimed: used, withdrawn, expired, a state this code cannot read, a profile + * no longer managed, or a creator who lost access. The one definition of + * "usable" the preview, the single-sign-on start and the callback share. */ -export async function previewHandover( - rawToken: string, - now: Date = new Date(), -): Promise { - const row = await findHandoverByToken(prisma, rawToken); +async function usableHandover( + row: Awaited>, + now: Date, +): Promise<{ + row: NonNullable>>; + state: HandoverState; + displayName: string | null; +} | null> { if (!row || !isOpen(row, now)) return null; const state = parseHandoverState(row.proposalsJson); if (!state) return null; @@ -467,6 +469,26 @@ export async function previewHandover( if (!(await isActiveGuardian(prisma, row.profileId, row.createdById, now))) { return null; } + return { row, state, displayName: profile.displayName }; +} + +/** + * What a link would hand over, or null for every kind of unusable link. + * + * Deliberately minimal: the profile's name, the expiry, and who keeps which + * access. No health data, no date of birth, no usernames and no grant ids — + * the person holding the link has not proved anything yet beyond holding it. + */ +export async function previewHandover( + rawToken: string, + now: Date = new Date(), +): Promise { + const usable = await usableHandover( + await findHandoverByToken(prisma, rawToken), + now, + ); + if (!usable) return null; + const { row, state } = usable; const guardians = await prisma.accountGrant.findMany({ where: { grantorId: row.profileId, ...activeGuardianWhere(now) }, @@ -477,7 +499,7 @@ export async function previewHandover( orderBy: { createdAt: "asc" }, }); return { - displayName: profile.displayName, + displayName: usable.displayName, expiresAt: row.expiresAt, guardians: guardians.map((g) => ({ displayName: g.grantee.displayName?.trim() || null, @@ -486,6 +508,39 @@ export async function previewHandover( }; } +/** + * v1.43 (#959) — the usable link behind a raw token, or null. The + * single-sign-on start seals its id, never the token, into the claim cookie. + */ +export async function usableHandoverForToken( + rawToken: string, + now: Date = new Date(), +): Promise<{ handoverId: string; profileId: string } | null> { + const usable = await usableHandover( + await findHandoverByToken(prisma, rawToken), + now, + ); + return usable + ? { handoverId: usable.row.id, profileId: usable.row.profileId } + : null; +} + +/** + * v1.43 (#959) — whether the link with this id can still be claimed. The + * single-sign-on legs re-ask on every step, so a link withdrawn or replaced + * while the person is at the identity provider is refused before any account + * changes. The claim re-checks under the lock; this decides nothing alone. + */ +export async function isHandoverUsable( + handoverId: string, + now: Date = new Date(), +): Promise { + const row = await prisma.managedProfileHandover.findUnique({ + where: { id: handoverId }, + }); + return (await usableHandover(row, now)) !== null; +} + export interface ClaimedProfile { profileId: string; username: string; @@ -502,15 +557,33 @@ export interface ClaimedProfile { * strength: none of that work belongs inside the lock. */ export async function claimManagedProfile(input: { - rawToken: string; + /** + * The link: the raw token from the password form, or (v1.43) the id the + * single-sign-on start sealed into the claim cookie after checking the + * token itself. + */ + link: { rawToken: string } | { handoverId: string }; username: string; - email: string; - passwordHash: string; + /** Null on a single-sign-on claim whose provider vouched for no address. */ + email: string | null; + /** Null on a single-sign-on claim: the account signs in at the provider. */ + passwordHash: string | null; + /** + * v1.43 (#959) — the identity provider's subject, bound to the profile in + * the same transaction. The `(issuer, sub)` unique index refuses a subject + * that another account holds, as `taken`. + */ + oidc?: { issuer: string; sub: string }; ipAddress?: string | null; now?: Date; }): Promise { const now = input.now ?? new Date(); - const found = await findHandoverByToken(prisma, input.rawToken); + const found = + "rawToken" in input.link + ? await findHandoverByToken(prisma, input.link.rawToken) + : await prisma.managedProfileHandover.findUnique({ + where: { id: input.link.handoverId }, + }); if (!found) throw new HandoverError("invalid"); try { @@ -551,6 +624,9 @@ export async function claimManagedProfile(input: { username: input.username, email: input.email, passwordHash: input.passwordHash, + ...(input.oidc + ? { oidcIssuer: input.oidc.issuer, oidcSub: input.oidc.sub } + : {}), disclaimerAcknowledgedAt: null, disclaimerAcknowledgedVersion: null, onboardingCompletedAt: null, @@ -636,6 +712,7 @@ export async function claimManagedProfile(input: { })), withdrawnInvitations, consentsWithdrawn: consent.revoked.length, + method: input.oidc ? "sso" : "password", }, client: tx, }); diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index b2acc7860..da98bc184 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -350,6 +350,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = profile_claim: [ "profile_claim.invalid", "profile_claim.oidc_only_unsupported", + "profile_claim.sso_unavailable", "profile_claim.taken", ], record_write: ["record_write.rate_limited"], diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index 8f0d1ff35..eeea2cb37 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -736,7 +736,7 @@ export const accountSharingPaths: NonNullable = { }, "403": { description: - "The instance allows single sign-on only (`meta.errorCode: profile_claim.oidc_only_unsupported`).", + "Neither a password nor single sign-on could redeem a link on this instance (`meta.errorCode: profile_claim.oidc_only_unsupported`). Since v1.43 a single-sign-on-only instance hands over through the identity provider, so a working configuration never answers this.", content: { "application/json": { schema: errorEnvelope } }, }, "404": { diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 4103ae971..2556bb59d 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -43,7 +43,6 @@ import { } from "@/lib/validations/step-up"; import { dataEnvelope, - notImplementedResponse, profileEmailRateLimitResponse, stdResponses, errorEnvelope, @@ -768,7 +767,7 @@ export const authPaths: NonNullable = { tags: ["Auth"], summary: "Claim a managed profile through single sign-on (v1.43)", description: - "Web only. Checks the link and the username, sets a sealed `hl_claim` cookie (10 minutes, `httpOnly`) and answers where to continue: `/api/auth/oidc/login?intent=claim`. The OIDC callback binds the identity provider's subject to the profile and signs the new owner in; a subject already bound to an account, or a verified email another account holds, is refused there. Works on a single-sign-on-only instance. 404 `profile_claim.invalid` (no usable link) or `profile_claim.sso_unavailable` (no identity provider configured); 409 `profile_claim.taken` (username) or `auth.already_authenticated`; rate-limited per source address like the password claim.", + "Web only. Checks the link and the username, sets a sealed `hl_claim` cookie (10 minutes, `httpOnly`) and answers where to continue: `/api/auth/oidc/login?intent=claim`. The OIDC callback binds the identity provider's subject to the profile and signs the new owner in; a subject already bound to an account, or a verified email another account holds, is refused there. Works on a single-sign-on-only instance. When the callback refuses, the browser lands on `/auth/claim?error=` with reason `claim_expired` (the link was used, withdrawn, replaced or expired in the meantime, or the cookie is missing, expired or belongs to another start), `claim_sso_subject_bound`, `claim_email_taken`, `claim_username_taken` (taken during the round trip), `claim_signed_in`, or one of the sign-in reasons (`oidc_denied`, `oidc_state`, `oidc_failed`, `oidc_rate_limited`, `oidc_disabled`); on success it lands on `/onboarding/handover`, where the new owner decides each guardian's access. The cookie is cleared either way. 404 `profile_claim.invalid` (no usable link) or `profile_claim.sso_unavailable` (no identity provider configured); 409 `profile_claim.taken` (username) or `auth.already_authenticated`; rate-limited per source address like the password claim.", requestBody: { required: true, content: { "application/json": { schema: claimSsoRequest } }, @@ -795,7 +794,6 @@ export const authPaths: NonNullable = { content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, - ...notImplementedResponse, }, }, }, @@ -807,7 +805,7 @@ export const authPaths: NonNullable = { tags: ["Auth"], summary: "Preview a managed-profile handover link", description: - "What a handover link would hand over: the profile's display name, the link's expiry, and each guardian's proposal. Anonymous; the token travels in the body. An unknown, expired, used or withdrawn token, and a link whose creator lost access, all answer the same 404. Rate-limited per source address.", + "What a handover link would hand over: the profile's display name, the link's expiry, and each guardian's proposal. Anonymous; the token travels in the body. An unknown, expired, used or withdrawn token, and a link whose creator lost access, all answer the same 404. Rate-limited per source address. Since v1.43 answered on a single-sign-on-only instance too, with `methods` naming the ways in.", requestBody: { required: true, content: { "application/json": { schema: claimPreviewRequest } }, @@ -821,11 +819,6 @@ export const authPaths: NonNullable = { }, }, }, - "403": { - description: - "The instance allows single sign-on only (`meta.errorCode: profile_claim.oidc_only_unsupported`).", - content: { "application/json": { schema: errorEnvelope } }, - }, "404": { description: "No usable link behind this token (`meta.errorCode: profile_claim.invalid`). Every failure class answers identically.", @@ -2338,6 +2331,12 @@ export const authPaths: NonNullable = { .describe( "Browser leg only: where to land after login. Sanitised to an in-app path; ignored entirely on the native leg.", ), + intent: z + .literal("claim") + .optional() + .describe( + "v1.43 — `claim` continues a managed-profile claim started at POST /api/auth/claim/sso; the link and username ride the sealed `hl_claim` cookie that call set, never this URL. Without a valid cookie for a still-usable link the start redirects to `/auth/claim?error=claim_expired`. Browser leg only: with `client=native` it fails with `oidc_invalid_request`. `next` is ignored.", + ), client_proto: z .enum(["http", "https"]) .optional() @@ -2356,7 +2355,7 @@ export const authPaths: NonNullable = { responses: { "302": { description: - "Always a redirect. To the provider on success. On failure to `/auth/login?error=` (browser) or `healthlog://oidc-callback?error=` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`, and on the browser leg `insecure_transport` (see `client_proto`). The provider then returns to the callback (not listed here, because the identity provider drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with `code=`, with `mfa_ticket=&methods=`, or with `error=`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `oidc_failed`. `oidc_link_required` means the provider's e-mail matches an existing local account that is not linked yet; the owner links it by signing in once with that account's own password or passkey in a browser. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic sign-in failure.", + "Always a redirect. To the provider on success. On failure to `/auth/login?error=` (browser; `/auth/claim?error=` with `intent=claim`, where `claim_expired` is one more reason) or `healthlog://oidc-callback?error=` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`, and on the browser leg `insecure_transport` (see `client_proto`). The provider then returns to the callback (not listed here, because the identity provider drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with `code=`, with `mfa_ticket=&methods=`, or with `error=`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `oidc_failed`. `oidc_link_required` means the provider's e-mail matches an existing local account that is not linked yet; the owner links it by signing in once with that account's own password or passkey in a browser. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic sign-in failure.", }, }, }, diff --git a/src/lib/validations/claim-sso.ts b/src/lib/validations/claim-sso.ts index e53466a1b..5e14ac63b 100644 --- a/src/lib/validations/claim-sso.ts +++ b/src/lib/validations/claim-sso.ts @@ -15,3 +15,4 @@ export type ClaimSsoInput = z.infer; /** How a handover link can be redeemed on this server. */ export const HANDOVER_METHODS = ["password", "sso"] as const; +export type HandoverMethod = (typeof HANDOVER_METHODS)[number]; diff --git a/tests/integration/managed-profile-handover.test.ts b/tests/integration/managed-profile-handover.test.ts index b40171a30..f7ab2d0a1 100644 --- a/tests/integration/managed-profile-handover.test.ts +++ b/tests/integration/managed-profile-handover.test.ts @@ -811,7 +811,7 @@ describe("claiming the profile", () => { // cookie jar, and the race is between the two transactions, not cookies. const [claimed, minted] = await Promise.allSettled([ claimManagedProfile({ - rawToken: first, + link: { rawToken: first }, username: "racer", email: "racer@example.test", passwordHash: "hash", From 3fd6bd6081c088f3d1ebb312bafd440269b40c25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 23:18:24 +0200 Subject: [PATCH 2/7] test(claim): drive the single-sign-on claim against a mock identity provider Check the link before the code is redeemed and refuse a live session first, so a refused claim spends nothing at the provider. --- src/app/api/auth/oidc/callback/route.ts | 27 +- src/lib/queries/use-profile-claim.ts | 36 + src/lib/query-keys/sharing.ts | 2 + .../managed-profile-handover-sso.test.ts | 906 ++++++++++++++++++ 4 files changed, 959 insertions(+), 12 deletions(-) create mode 100644 tests/integration/managed-profile-handover-sso.test.ts diff --git a/src/app/api/auth/oidc/callback/route.ts b/src/app/api/auth/oidc/callback/route.ts index f5d155a55..8f39e6457 100644 --- a/src/app/api/auth/oidc/callback/route.ts +++ b/src/app/api/auth/oidc/callback/route.ts @@ -49,6 +49,7 @@ import { completeSsoClaim, SSO_CLAIM_LANDING, } from "@/lib/managed-profiles/claim-sso"; +import { isHandoverUsable } from "@/lib/managed-profiles/handover"; const LOGIN_ERROR_URL = "/auth/login"; @@ -283,22 +284,16 @@ export const GET = apiHandler(async (req: NextRequest) => { return isClaim ? failRedirect("oidc_state") : errorRedirect("oidc_state"); } - // v1.43 (#959) — a claim is admitted only with its own sealed cookie, still - // bound to this sign-in, and never over a live session (the claim routes' - // rule: a Guardian finishing their own link would otherwise be signed out - // of their account and into the person's). Both before the code is - // redeemed, so a refused claim spends nothing at the provider. + // v1.43 (#959) — a claim is never finished over a live session (the claim + // routes' rule: a Guardian finishing their own link would otherwise be + // signed out of their account and into the person's), and is admitted only + // with its own sealed cookie, still bound to this sign-in, for a link that + // is still usable. All of it before the code is redeemed, so a refused claim + // spends nothing at the provider. const pendingClaim = isClaim ? readClaimCookie(req.cookies.get(CLAIM_COOKIE)?.value) : null; if (isClaim) { - if ( - !pendingClaim || - !claimMatchesBinding(pendingClaim, stored.claimBinding as string) - ) { - annotate({ meta: { reason: "claim_cookie" } }); - return failRedirect("claim_expired"); - } const liveSession = await validateSessionFromCookieValue( req.cookies.get(SESSION_COOKIE_NAME)?.value, ); @@ -306,6 +301,14 @@ export const GET = apiHandler(async (req: NextRequest) => { annotate({ meta: { reason: "claim_signed_in" } }); return failRedirect("claim_signed_in"); } + if ( + !pendingClaim || + !claimMatchesBinding(pendingClaim, stored.claimBinding as string) || + !(await isHandoverUsable(pendingClaim.handoverId)) + ) { + annotate({ meta: { reason: "claim_expired" } }); + return failRedirect("claim_expired"); + } } try { diff --git a/src/lib/queries/use-profile-claim.ts b/src/lib/queries/use-profile-claim.ts index 9c4b23910..160a3ff69 100644 --- a/src/lib/queries/use-profile-claim.ts +++ b/src/lib/queries/use-profile-claim.ts @@ -12,6 +12,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { apiPost } from "@/lib/api/api-fetch"; import type { HandoverAccess } from "@/lib/managed-profiles/handover-access"; +import type { HandoverMethod } from "@/lib/validations/claim-sso"; import { queryKeys } from "@/lib/query-keys"; export interface ProfileClaimPreview { @@ -21,6 +22,13 @@ export interface ProfileClaimPreview { displayName: string | null; proposal: HandoverAccess; }[]; + /** + * v1.43 — the ways in this server offers. Absent from an older server, + * which only ever offered the password. + */ + methods?: HandoverMethod[]; + /** v1.43 — the provider's name for the button, or null. */ + ssoProviderName?: string | null; } export function useProfileClaimPreview(token: string | null, enabled: boolean) { @@ -64,3 +72,31 @@ export function useClaimProfile() { ), }); } + +/** Exactly the body `POST /api/auth/claim/sso` accepts (strict). */ +export interface ProfileClaimSsoInput { + token: string; + username: string; +} + +export function claimSsoBody( + input: ProfileClaimSsoInput, +): ProfileClaimSsoInput { + return { token: input.token, username: input.username }; +} + +/** + * v1.43 (#959) — start claiming through the identity provider. The answer + * sets the sealed claim cookie and names where the browser goes next; the + * caller navigates there as a full page load. + */ +export function useClaimProfileSso() { + return useMutation({ + mutationKey: queryKeys.profileClaimSso(), + mutationFn: (input: ProfileClaimSsoInput) => + apiPost<{ redirectTo: string }>( + "/api/auth/claim/sso", + claimSsoBody(input), + ), + }); +} diff --git a/src/lib/query-keys/sharing.ts b/src/lib/query-keys/sharing.ts index 48755f2d4..38a071974 100644 --- a/src/lib/query-keys/sharing.ts +++ b/src/lib/query-keys/sharing.ts @@ -99,4 +99,6 @@ export const sharingKeys = { ["managed-profiles", "handover", "revoke"] as const, handoverDecide: () => ["account", "handover-decision", "decide"] as const, profileClaim: () => ["profile-claim", "submit"] as const, + /** v1.43 (#959) — starting the claim through single sign-on. */ + profileClaimSso: () => ["profile-claim", "sso"] as const, }; diff --git a/tests/integration/managed-profile-handover-sso.test.ts b/tests/integration/managed-profile-handover-sso.test.ts new file mode 100644 index 000000000..842e9e8c7 --- /dev/null +++ b/tests/integration/managed-profile-handover-sso.test.ts @@ -0,0 +1,906 @@ +/** + * v1.43 (#959) — handing a managed profile over on a server that signs in + * through an identity provider, end to end: the real start route, the real + * OIDC login and callback, a real Postgres, and a mock identity provider that + * speaks the wire (discovery, JWKS, a token endpoint that checks PKCE, and + * ES256-signed ID tokens), so state, nonce, PKCE and the signature are the + * production code's own checks rather than stubs. + * + * Every refusal is asserted twice: where the browser lands, and that nothing + * about any account changed (the profile is still managed, the subject is not + * bound, nobody got a session, the link is still as it was). + */ +import { createServer, type Server } from "node:http"; +import { createHash } from "node:crypto"; +import type { AddressInfo } from "node:net"; + +import { exportJWK, generateKeyPair, SignJWT, type CryptoKey } from "jose"; +import { NextRequest } from "next/server"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; +import { createManagedProfile } from "@/lib/managed-profiles/create"; +import { acceptGrant, inviteGrant } from "@/lib/sharing/grants"; +import { readPendingHandoverDecision } from "@/lib/managed-profiles/handover"; +import { _resetOidcCacheForTests } from "@/lib/auth/oidc"; +import { handoverCreateBody } from "@/lib/queries/use-managed-profiles"; +import { claimBody, claimSsoBody } from "@/lib/queries/use-profile-claim"; +import type { HandoverAccess } from "@/lib/managed-profiles/handover-access"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/password-breach-check", () => ({ + checkPasswordBreachIfEnabled: vi.fn().mockResolvedValue(null), +})); + +const sent: { userId: string; messageKey: string }[] = []; +vi.mock("@/lib/notifications/dispatch-localised", () => ({ + dispatchLocalisedNotification: vi.fn( + async (opts: { userId: string; messageKey: string }) => { + sent.push({ userId: opts.userId, messageKey: opts.messageKey }); + }, + ), +})); + +const APP = "http://localhost:3000"; +const CLIENT_ID = "healthlog-test"; +const CLIENT_SECRET = "client-secret-for-the-mock-idp"; + +// ── The mock identity provider ────────────────────────────────────────────── + +interface Grant { + sub: string; + email?: string; + emailVerified?: boolean; + nonce: string; + codeChallenge: string; + /** The provider would redeem the code more than once (a replay test). */ + reusable?: boolean; + /** Sign the ID token with this nonce instead of the requested one. */ + nonceOverride?: string; +} + +let idp: Server; +let ISSUER = ""; +let privateKey: CryptoKey; +let publicJwk: Record; +const grants = new Map(); +const redeemed: string[] = []; + +function s256(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); +} + +async function readBody(req: import("node:http").IncomingMessage) { + let body = ""; + for await (const chunk of req) body += chunk; + return new URLSearchParams(body); +} + +beforeAll(async () => { + const pair = await generateKeyPair("ES256"); + privateKey = pair.privateKey; + publicJwk = { ...(await exportJWK(pair.publicKey)), kid: "k1", alg: "ES256" }; + + idp = createServer(async (req, res) => { + const json = (status: number, body: unknown) => { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); + }; + const path = new URL(req.url ?? "/", ISSUER).pathname; + if (path === "/.well-known/openid-configuration") { + return json(200, { + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/authorize`, + token_endpoint: `${ISSUER}/token`, + jwks_uri: `${ISSUER}/jwks`, + }); + } + if (path === "/jwks") return json(200, { keys: [publicJwk] }); + if (path === "/token" && req.method === "POST") { + const form = await readBody(req); + const code = form.get("code") ?? ""; + const grant = grants.get(code); + if ( + !grant || + form.get("client_id") !== CLIENT_ID || + form.get("client_secret") !== CLIENT_SECRET || + form.get("redirect_uri") !== `${APP}/api/auth/oidc/callback` || + // PKCE, as a provider enforces it. + s256(form.get("code_verifier") ?? "") !== grant.codeChallenge + ) { + return json(400, { error: "invalid_grant" }); + } + if (!grant.reusable) grants.delete(code); + redeemed.push(code); + const idToken = await new SignJWT({ + nonce: grant.nonceOverride ?? grant.nonce, + ...(grant.email !== undefined ? { email: grant.email } : {}), + ...(grant.emailVerified !== undefined + ? { email_verified: grant.emailVerified } + : {}), + }) + .setProtectedHeader({ alg: "ES256", kid: "k1" }) + .setIssuer(ISSUER) + .setAudience(CLIENT_ID) + .setSubject(grant.sub) + .setIssuedAt() + .setExpirationTime("5m") + .sign(privateKey); + return json(200, { id_token: idToken, token_type: "Bearer" }); + } + json(404, { error: "not_found" }); + }); + await new Promise((resolve) => idp.listen(0, "127.0.0.1", resolve)); + ISSUER = `http://127.0.0.1:${(idp.address() as AddressInfo).port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => idp.close(() => resolve())); +}); + +// ── The household ─────────────────────────────────────────────────────────── + +let sequence = 0; + +async function person(label: string) { + const suffix = sequence++; + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: `${label}-${suffix}`, + email: `${label}-${suffix}@example.test`, + totpConfirmedAt: new Date(), + }, + }); + const session = await prisma.session.create({ + data: { + userId: user.id, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + mfaVerifiedAt: new Date(), + }, + }); + return { id: user.id, username: user.username, sessionId: session.id }; +} + +/** A profile with two Guardians: its creator and one more. */ +async function household() { + const creator = await person("creator"); + const second = await person("second"); + const { profile, creatorGrant } = await createManagedProfile({ + creatorId: creator.id, + displayName: "Robin", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const invitation = await inviteGrant({ + grantorId: profile.id, + granteeId: second.id, + access: "MANAGE", + scope: null, + }); + const secondGrant = await acceptGrant({ + grantId: invitation.id, + granteeId: second.id, + }); + return { creator, second, profile, creatorGrant, secondGrant }; +} + +const json = (body: unknown) => ({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), +}); + +/** Mint a link as the creator (the Guardian side), then sign out. */ +async function mintToken( + creator: { sessionId: string }, + profileId: string, + proposals: { grantId: string; proposal: HandoverAccess }[] = [], +): Promise { + cookieJar.set("healthlog_session", creator.sessionId); + const { POST } = + await import("@/app/api/managed-profiles/[id]/handover/route"); + const response = await POST( + new NextRequest( + `${APP}/api/managed-profiles/${profileId}/handover`, + json(handoverCreateBody({ profileId, expiresInDays: 7, proposals })), + ), + { params: Promise.resolve({ id: profileId }) }, + ); + expect(response.status).toBe(201); + cookieJar.clear(); + return (await response.json()).data.token as string; +} + +async function handoverStatus(creator: { sessionId: string }, id: string) { + cookieJar.set("healthlog_session", creator.sessionId); + const { GET } = + await import("@/app/api/managed-profiles/[id]/handover/route"); + const response = await GET( + new NextRequest(`${APP}/api/managed-profiles/${id}/handover`), + { params: Promise.resolve({ id }) }, + ); + cookieJar.clear(); + return response; +} + +async function preview(token: string) { + const { POST } = await import("@/app/api/auth/claim/preview/route"); + return POST( + new NextRequest(`${APP}/api/auth/claim/preview`, json({ token })), + ); +} + +async function passwordClaim(token: string) { + const suffix = sequence++; + const { POST } = await import("@/app/api/auth/claim/route"); + return POST( + new NextRequest( + `${APP}/api/auth/claim`, + json( + claimBody({ + token, + username: `robinpw${suffix}`, + email: `robin-pw-${suffix}@example.test`, + password: "correct horse battery staple 1959", + }), + ), + ), + ); +} + +async function startSso(token: string, username: string) { + const { POST } = await import("@/app/api/auth/claim/sso/route"); + return POST( + new NextRequest( + `${APP}/api/auth/claim/sso`, + json(claimSsoBody({ token, username })), + ), + ); +} + +function cookieHeader(cookies: Record): string { + return Object.entries(cookies) + .filter((entry): entry is [string, string] => Boolean(entry[1])) + .map(([k, v]) => `${k}=${v}`) + .join("; "); +} + +/** The value a response sets for a cookie, or undefined. */ +function setCookie(response: Response, name: string): string | undefined { + const cookie = (response as NextResponseLike).cookies?.get(name); + return cookie?.value || undefined; +} +type NextResponseLike = Response & { + cookies?: { get(name: string): { value: string } | undefined }; +}; + +async function login(claimCookie: string | undefined, query = "intent=claim") { + const { GET } = await import("@/app/api/auth/oidc/login/route"); + return GET( + new NextRequest(`${APP}/api/auth/oidc/login?${query}`, { + headers: { cookie: cookieHeader({ hl_claim: claimCookie }) }, + }), + ); +} + +interface Identity { + sub: string; + email?: string; + emailVerified?: boolean; + reusable?: boolean; + nonceOverride?: string; +} + +/** Where the provider sends the browser back, and with which cookies. */ +interface Callback { + url: string; + stateCookie: string; + claimCookie: string | undefined; +} + +/** The provider authenticates `identity` for an authorize redirect. */ +function authorize(loginResponse: Response, identity: Identity, code: string) { + const location = new URL(loginResponse.headers.get("location") ?? ""); + expect(location.origin + location.pathname).toBe(`${ISSUER}/authorize`); + expect(location.searchParams.get("code_challenge_method")).toBe("S256"); + grants.set(code, { + ...identity, + nonce: location.searchParams.get("nonce") ?? "", + codeChallenge: location.searchParams.get("code_challenge") ?? "", + }); + return location.searchParams.get("state") ?? ""; +} + +async function callback( + cb: Callback, + extra: Record = {}, +) { + const { GET } = await import("@/app/api/auth/oidc/callback/route"); + return GET( + new NextRequest(cb.url, { + headers: { + cookie: cookieHeader({ + oidc_auth_state: cb.stateCookie, + hl_claim: cb.claimCookie, + ...extra, + }), + }, + }), + ); +} + +/** Start, log in and come back from the provider; the callback not yet run. */ +async function throughProvider( + token: string, + identity: Identity, + username = `robin${sequence++}`, +): Promise { + const started = await startSso(token, username); + expect(started.status).toBe(200); + expect((await started.json()).data.redirectTo).toBe( + "/api/auth/oidc/login?intent=claim", + ); + const claimCookie = setCookie(started, "hl_claim"); + expect(claimCookie).toBeDefined(); + const loginResponse = await login(claimCookie); + const stateCookie = setCookie(loginResponse, "oidc_auth_state"); + expect(stateCookie).toBeDefined(); + const code = `code-${sequence++}`; + const state = authorize(loginResponse, identity, code); + return { + url: `${APP}/api/auth/oidc/callback?code=${code}&state=${encodeURIComponent(state)}`, + stateCookie: stateCookie!, + claimCookie, + }; +} + +function landedOn(response: Response): string { + expect([302, 303, 307]).toContain(response.status); + const location = new URL(response.headers.get("location") ?? ""); + expect(location.origin).toBe(APP); + return location.pathname + location.search; +} + +async function profileRow(id: string) { + return getPrismaClient().user.findUniqueOrThrow({ + where: { id }, + select: { + username: true, + email: true, + passwordHash: true, + oidcIssuer: true, + oidcSub: true, + managedProfileAt: true, + onboardingCompletedAt: true, + }, + }); +} + +/** The account did not change at all, and nobody signed in to it. */ +async function expectUntouched(profileId: string) { + const row = await profileRow(profileId); + expect(row.managedProfileAt).not.toBeNull(); + expect(row.oidcSub).toBeNull(); + expect(row.email).toBeNull(); + expect(row.username.startsWith("managed-")).toBe(true); + expect( + await getPrismaClient().session.count({ where: { userId: profileId } }), + ).toBe(0); + expect( + await getPrismaClient().auditLog.count({ + where: { userId: profileId, action: "auth.claim" }, + }), + ).toBe(0); +} + +function oidcEnv(only: boolean) { + process.env.OIDC_ISSUER_URL = ISSUER; + process.env.OIDC_CLIENT_ID = CLIENT_ID; + process.env.OIDC_CLIENT_SECRET = CLIENT_SECRET; + process.env.OIDC_BUTTON_LABEL = "Example ID"; + if (only) process.env.OIDC_ONLY = "true"; + else delete process.env.OIDC_ONLY; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + grants.clear(); + redeemed.length = 0; + sent.length = 0; + _resetOidcCacheForTests(); + process.env.APP_URL = APP; + process.env.NEXT_PUBLIC_APP_URL = APP; + oidcEnv(true); +}); + +afterEach(() => { + for (const key of [ + "OIDC_ISSUER_URL", + "OIDC_CLIENT_ID", + "OIDC_CLIENT_SECRET", + "OIDC_BUTTON_LABEL", + "OIDC_ONLY", + ]) { + delete process.env[key]; + } +}); + +// ── The tests ─────────────────────────────────────────────────────────────── + +describe("claiming through single sign-on on an SSO-only server", () => { + it("hands the profile over and binds the provider's subject", async () => { + const { creator, second, profile, secondGrant } = await household(); + + // The Guardian side: available, through single sign-on only. + const status = (await (await handoverStatus(creator, profile.id)).json()) + .data; + expect(status.available).toBe(true); + expect(status.methods).toEqual(["sso"]); + + const token = await mintToken(creator, profile.id, [ + { grantId: secondGrant.id, proposal: "read" }, + ]); + + // The person's side: the preview names the provider, the password claim + // stays closed. + const shown = (await (await preview(token)).json()).data; + expect(shown.methods).toEqual(["sso"]); + expect(shown.ssoProviderName).toBe("Example ID"); + const refused = await passwordClaim(token); + expect(refused.status).toBe(403); + expect((await refused.json()).meta.errorCode).toBe( + "profile_claim.oidc_only_unsupported", + ); + + const cb = await throughProvider( + token, + { + sub: "subject-robin", + email: "Robin@Example.test", + emailVerified: true, + }, + "robin", + ); + const response = await callback(cb); + expect(landedOn(response)).toBe("/onboarding/handover"); + // Both single-use cookies go with the answer. + expect(response.headers.get("set-cookie")).toMatch(/hl_claim=;/); + expect(response.headers.get("set-cookie")).toMatch(/oidc_auth_state=;/); + // The provider redeemed the code with the matching PKCE verifier. + expect(redeemed).toHaveLength(1); + + const row = await profileRow(profile.id); + expect(row).toMatchObject({ + username: "robin", + email: "robin@example.test", + passwordHash: null, + oidcIssuer: ISSUER, + oidcSub: "subject-robin", + managedProfileAt: null, + onboardingCompletedAt: null, + }); + + // The link is spent; a session exists for the new owner, and only one. + const link = + await getPrismaClient().managedProfileHandover.findFirstOrThrow({ + where: { profileId: profile.id }, + }); + expect(link.usedAt).not.toBeNull(); + expect( + await getPrismaClient().session.count({ where: { userId: profile.id } }), + ).toBe(1); + expect(cookieJar.get("healthlog_session")).toBeDefined(); + + // The Guardians' proposals hold and wait for the owner's decision. + const decision = await readPendingHandoverDecision(profile.id); + expect( + decision?.guardians.map((g) => [g.displayName, g.proposal, g.current]), + ).toEqual( + expect.arrayContaining([ + [expect.stringContaining("second"), "read", "read"], + ]), + ); + const secondNow = await getPrismaClient().accountGrant.findFirst({ + where: { grantorId: profile.id, granteeId: second.id, revokedAt: null }, + }); + expect(secondNow?.access).toBe("READ"); + + // Audited as an SSO claim, and the Guardians were told. + const audits = await getPrismaClient().auditLog.findMany({ + where: { userId: profile.id }, + select: { action: true, details: true }, + }); + const actions = audits.map((a) => a.action); + expect(actions).toEqual( + expect.arrayContaining([ + "managed_profile.claim_sso.started", + "managed_profile.claimed", + "auth.claim", + ]), + ); + const claimAudit = audits.find((a) => a.action === "auth.claim"); + expect(JSON.parse(claimAudit!.details!)).toEqual({ method: "sso" }); + // The token never reached an audit row. + expect(JSON.stringify(audits)).not.toContain(token); + expect(sent.map((s) => s.userId).sort()).toEqual( + [creator.id, second.id].sort(), + ); + }); + + it("creates the account without an address the provider did not verify", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { + sub: "subject-unverified", + email: "robin@example.test", + emailVerified: false, + }); + expect(landedOn(await callback(cb))).toBe("/onboarding/handover"); + const row = await profileRow(profile.id); + expect(row.email).toBeNull(); + expect(row.oidcSub).toBe("subject-unverified"); + }); + + it("ignores a caller-supplied next and lands on the fixed page", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const started = await startSso(token, "robin"); + const claimCookie = setCookie(started, "hl_claim"); + const loginResponse = await login( + claimCookie, + "intent=claim&next=https%3A%2F%2Fevil.example%2Fphish", + ); + const state = authorize(loginResponse, { sub: "s-next" }, "code-next"); + const response = await callback({ + url: `${APP}/api/auth/oidc/callback?code=code-next&state=${encodeURIComponent(state)}`, + stateCookie: setCookie(loginResponse, "oidc_auth_state")!, + claimCookie, + }); + expect(response.headers.get("location")).toBe(`${APP}/onboarding/handover`); + }); +}); + +describe("refusals", () => { + it("refuses a subject already bound to another account (no double binding)", async () => { + const { creator, profile } = await household(); + const holder = await getPrismaClient().user.create({ + data: { + username: "holder", + email: "holder@example.test", + oidcIssuer: ISSUER, + oidcSub: "subject-taken", + }, + }); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { sub: "subject-taken" }); + + expect(landedOn(await callback(cb))).toBe( + "/auth/claim?error=claim_sso_subject_bound", + ); + await expectUntouched(profile.id); + // The holder keeps their sign-in and is told somebody tried. + expect((await profileRow(holder.id)).oidcSub).toBe("subject-taken"); + expect( + await getPrismaClient().auditLog.count({ + where: { userId: holder.id, action: "auth.oidc.claim_conflict" }, + }), + ).toBe(1); + // The link survives for the right person. + expect((await preview(token)).status).toBe(200); + }); + + it("refuses a second profile for a subject that already claimed one", async () => { + const first = await household(); + const second = await household(); + const tokenA = await mintToken(first.creator, first.profile.id); + const tokenB = await mintToken(second.creator, second.profile.id); + + const a = await throughProvider(tokenA, { sub: "one-person" }); + expect(landedOn(await callback(a))).toBe("/onboarding/handover"); + cookieJar.clear(); + + const b = await throughProvider(tokenB, { sub: "one-person" }); + expect(landedOn(await callback(b))).toBe( + "/auth/claim?error=claim_sso_subject_bound", + ); + await expectUntouched(second.profile.id); + }); + + it("refuses a verified address another account holds", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { + sub: "subject-email", + email: creator.username + "@EXAMPLE.test", + emailVerified: true, + }); + expect(landedOn(await callback(cb))).toBe( + "/auth/claim?error=claim_email_taken", + ); + await expectUntouched(profile.id); + }); + + it("refuses a username taken during the round trip", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { sub: "subject-name" }, "robin"); + await getPrismaClient().user.create({ + data: { username: "robin", email: "other@example.test" }, + }); + expect(landedOn(await callback(cb))).toBe( + "/auth/claim?error=claim_username_taken", + ); + await expectUntouched(profile.id); + }); + + it.each([ + [ + "expired", + async (profileId: string) => { + await getPrismaClient().managedProfileHandover.updateMany({ + where: { profileId }, + data: { expiresAt: new Date(Date.now() - 1000) }, + }); + }, + ], + [ + "withdrawn", + async (profileId: string) => { + await getPrismaClient().managedProfileHandover.updateMany({ + where: { profileId }, + data: { revokedAt: new Date() }, + }); + }, + ], + ])( + "refuses a link %s while the person was at the provider", + async (_n, spoil) => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { sub: "subject-late" }); + await spoil(profile.id); + expect(landedOn(await callback(cb))).toBe( + "/auth/claim?error=claim_expired", + ); + await expectUntouched(profile.id); + // Refused before the code was redeemed: nothing spent at the provider. + expect(redeemed).toHaveLength(0); + }, + ); + + it("refuses a link used by a password claim in the meantime", async () => { + oidcEnv(false); + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { sub: "subject-slow" }); + expect((await passwordClaim(token)).status).toBe(201); + cookieJar.clear(); + expect(landedOn(await callback(cb))).toBe( + "/auth/claim?error=claim_expired", + ); + const row = await profileRow(profile.id); + expect(row.oidcSub).toBeNull(); + expect(row.passwordHash).not.toBeNull(); + }); + + it("stops an expired link at the login, before the provider", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const started = await startSso(token, "robin"); + await getPrismaClient().managedProfileHandover.updateMany({ + where: { profileId: profile.id }, + data: { expiresAt: new Date(Date.now() - 1000) }, + }); + const response = await login(setCookie(started, "hl_claim")); + expect(landedOn(response)).toBe("/auth/claim?error=claim_expired"); + expect(setCookie(response, "oidc_auth_state")).toBeUndefined(); + }); + + it("answers the start with 404 for a used or unknown link", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { sub: "subject-once" }); + expect(landedOn(await callback(cb))).toBe("/onboarding/handover"); + cookieJar.clear(); + for (const t of [token, `hlp_${"0".repeat(64)}`]) { + const response = await startSso(t, "someone"); + expect(response.status).toBe(404); + expect((await response.json()).meta.errorCode).toBe( + "profile_claim.invalid", + ); + } + }); +}); + +describe("replay, state, nonce and the claim cookie", () => { + it("does nothing on a replayed callback, even if the provider redeemed the code twice", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { + sub: "subject-replay", + reusable: true, + }); + expect(landedOn(await callback(cb))).toBe("/onboarding/handover"); + const sessionsAfterFirst = await getPrismaClient().session.count({ + where: { userId: profile.id }, + }); + const sessionCookie = cookieJar.get("healthlog_session"); + expect(sessionCookie).toMatch(/^hls_/); + cookieJar.clear(); + + // The same request again, cookies and all, from a browser without the + // session (a captured request): the link is spent. + expect(landedOn(await callback(cb))).toBe( + "/auth/claim?error=claim_expired", + ); + // From the browser that now holds the session: refused as signed in, + // before the link is even looked at. + expect( + landedOn(await callback(cb, { healthlog_session: sessionCookie })), + ).toBe("/auth/claim?error=claim_signed_in"); + + expect( + await getPrismaClient().session.count({ where: { userId: profile.id } }), + ).toBe(sessionsAfterFirst); + expect( + await getPrismaClient().auditLog.count({ + where: { userId: profile.id, action: "auth.claim" }, + }), + ).toBe(1); + }); + + it("refuses a callback whose state does not match", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { sub: "subject-state" }); + const forged = cb.url.replace(/state=[^&]+/, "state=forged-state-value"); + expect(landedOn(await callback({ ...cb, url: forged }))).toBe( + "/auth/claim?error=oidc_state", + ); + await expectUntouched(profile.id); + expect(redeemed).toHaveLength(0); + }); + + it("refuses an ID token minted for another nonce", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { + sub: "subject-nonce", + nonceOverride: "some-other-sign-in", + }); + expect(landedOn(await callback(cb))).toBe("/auth/claim?error=oidc_failed"); + await expectUntouched(profile.id); + }); + + it("refuses a callback that comes back without the claim cookie", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { sub: "subject-nocookie" }); + expect(landedOn(await callback({ ...cb, claimCookie: undefined }))).toBe( + "/auth/claim?error=claim_expired", + ); + await expectUntouched(profile.id); + expect(redeemed).toHaveLength(0); + }); + + it("refuses a claim cookie from a different start", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { sub: "subject-swap" }); + // A second start in the same browser seals a new binding; the sign-in + // that began with the first one cannot finish with it. + const other = setCookie(await startSso(token, "robin2"), "hl_claim"); + expect(landedOn(await callback({ ...cb, claimCookie: other }))).toBe( + "/auth/claim?error=claim_expired", + ); + await expectUntouched(profile.id); + }); + + it("refuses a forged claim cookie", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { sub: "subject-forged" }); + const link = + await getPrismaClient().managedProfileHandover.findFirstOrThrow({ + where: { profileId: profile.id }, + }); + const forged = Buffer.from( + JSON.stringify({ h: link.id, u: "x", b: "y", e: Date.now() + 1e6 }), + ).toString("base64"); + expect(landedOn(await callback({ ...cb, claimCookie: forged }))).toBe( + "/auth/claim?error=claim_expired", + ); + await expectUntouched(profile.id); + }); + + it("does not adopt a session id the browser brought along", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { sub: "subject-fixation" }); + const planted = "planted-session-id-chosen-by-somebody-else"; + expect(landedOn(await callback(cb, { healthlog_session: planted }))).toBe( + "/onboarding/handover", + ); + const sessions = await getPrismaClient().session.findMany({ + where: { userId: profile.id }, + select: { id: true }, + }); + expect(sessions).toHaveLength(1); + expect(sessions[0]!.id).not.toBe(planted); + expect(cookieJar.get("healthlog_session")).not.toBe(planted); + }); +}); + +describe("which ways in a server offers", () => { + it("offers both with a provider and passwords allowed, and both work", async () => { + oidcEnv(false); + const { creator, profile } = await household(); + const status = (await (await handoverStatus(creator, profile.id)).json()) + .data; + expect(status.methods).toEqual(["password", "sso"]); + const token = await mintToken(creator, profile.id); + expect((await (await preview(token)).json()).data.methods).toEqual([ + "password", + "sso", + ]); + const cb = await throughProvider(token, { sub: "subject-mixed" }); + expect(landedOn(await callback(cb))).toBe("/onboarding/handover"); + + const other = await household(); + const token2 = await mintToken(other.creator, other.profile.id); + expect((await passwordClaim(token2)).status).toBe(201); + }); + + it("offers only the password without a provider, and the SSO start says so", async () => { + for (const key of ["OIDC_ISSUER_URL", "OIDC_CLIENT_ID", "OIDC_ONLY"]) { + delete process.env[key]; + } + const { creator, profile } = await household(); + const status = (await (await handoverStatus(creator, profile.id)).json()) + .data; + expect(status.methods).toEqual(["password"]); + const token = await mintToken(creator, profile.id); + const shown = (await (await preview(token)).json()).data; + expect(shown.methods).toEqual(["password"]); + expect(shown.ssoProviderName).toBeNull(); + const response = await startSso(token, "robin"); + expect(response.status).toBe(404); + expect((await response.json()).meta.errorCode).toBe( + "profile_claim.sso_unavailable", + ); + }); +}); From 0ebb61343ef34bf74fa0001a253668e9ae2e03fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 23:34:38 +0200 Subject: [PATCH 3/7] feat(claim): offer the identity provider on the claim page and name the way in to guardians The claim page shows the password form, a continue-with-provider button, or both, as the preview names them, and explains a refused single-sign-on claim when the callback sends the browser back. The guardian's handover panel says how the person will sign in. --- messages/de.json | 22 +- messages/en.json | 22 +- messages/es.json | 22 +- messages/fr.json | 22 +- messages/it.json | 22 +- messages/ko.json | 22 +- messages/pl.json | 22 +- src/app/auth/claim/page.tsx | 11 +- .../__tests__/claim-profile-invalid.test.tsx | 1 + .../auth/__tests__/claim-profile-sso.test.tsx | 105 +++++++ src/components/auth/claim-profile.tsx | 263 ++++++++++++++---- .../access/managed-profile-handover.tsx | 12 + src/lib/queries/use-managed-profiles.ts | 8 +- 13 files changed, 469 insertions(+), 85 deletions(-) create mode 100644 src/components/auth/__tests__/claim-profile-sso.test.tsx diff --git a/messages/de.json b/messages/de.json index f467801d0..5078d6052 100644 --- a/messages/de.json +++ b/messages/de.json @@ -528,7 +528,7 @@ "loading": "Dein Link wird geprüft…", "invalid": "Dieser Link funktioniert nicht mehr", "invalidHint": "Er ist unbekannt, abgelaufen, zurückgezogen oder schon benutzt. Bitte die Person, die ihn dir gegeben hat, um einen neuen.", - "oidcOnly": "Dieser Server erlaubt nur Single Sign-on, deshalb lässt sich ein Profil hier noch nicht übernehmen.", + "oidcOnly": "Dieser Server erlaubt nur Single Sign-on. Fahre stattdessen mit deinem Identitätsanbieter fort.", "signedInTitle": "Melde dich zuerst ab", "signedInBody": "Du bist als {username} angemeldet. Dieser Link richtet eine Anmeldung für jemand anderen ein, melde dich also ab, bevor du ihn benutzt.", "signOut": "Abmelden und weiter", @@ -545,7 +545,19 @@ "errorTaken": "Dieser Benutzername oder diese E-Mail-Adresse ist schon vergeben.", "errorRateLimit": "Zu viele Versuche. Warte ein paar Minuten und versuche es erneut.", "errorFailed": "Das hat nicht geklappt. Versuche es gleich noch einmal.", - "errorOffline": "Das hat den Server nicht erreicht. Prüfe deine Verbindung und versuche es erneut." + "errorOffline": "Das hat den Server nicht erreicht. Prüfe deine Verbindung und versuche es erneut.", + "ssoContinue": "Weiter mit {provider}", + "ssoStarting": "Weiterleitung…", + "ssoHint": "Ab dann meldest du dich hier mit deinem Konto bei {provider} an.", + "ssoDivider": "oder", + "ssoFallbackName": "Single Sign-on", + "errorTitle": "Die Übernahme hat nicht geklappt", + "errorSubjectBound": "Diese Anmeldung gehört schon zu einem anderen Konto auf diesem Server. Öffne den Link noch einmal und nimm ein anderes Konto bei deinem Identitätsanbieter.", + "errorEmailTaken": "Dein Identitätsanbieter hat eine E-Mail-Adresse geliefert, die hier schon ein anderes Konto nutzt. Öffne den Link noch einmal und nimm ein anderes Konto, oder frag die Person, die den Server betreibt.", + "errorUsernameTaken": "Jemand hat diesen Benutzernamen inzwischen vergeben. Öffne den Link noch einmal und wähle einen anderen.", + "errorSignedIn": "Bei der Rückkehr warst du angemeldet. Melde dich ab und öffne den Link noch einmal.", + "errorSso": "Die Anmeldung bei deinem Identitätsanbieter hat nicht funktioniert. Öffne den Link noch einmal, um es erneut zu versuchen.", + "signInInstead": "Bei diesem Konto anmelden" }, "inviteInvalid": { "title": "Einladung ungültig", @@ -11900,9 +11912,11 @@ "revokeFailed": "Der Link ließ sich nicht zurückziehen. Versuche es erneut.", "errorFailed": "Der Übergabelink ließ sich nicht erstellen.", "errorRosterChanged": "Inzwischen hat sich geändert, wer die Akte betreut. Schließe das hier und öffne es erneut.", - "oidcOnly": "Ein Profil zu übergeben ist nicht möglich, solange dieser Server nur Single Sign-on erlaubt.", + "oidcOnly": "Ein Profil zu übergeben ist auf diesem Server nicht möglich.", "loadError": "Der Stand der Übergabe ließ sich nicht laden.", - "entryHint": "Wenn {name} die Akte selbst führen möchte." + "entryHint": "Wenn {name} die Akte selbst führen möchte.", + "methodSso": "{name} meldet sich beim Einlösen des Links über Single Sign-on an.", + "methodBoth": "{name} kann ein Passwort festlegen oder sich über Single Sign-on anmelden." } }, "toast": { diff --git a/messages/en.json b/messages/en.json index 6afd60c39..1d542da02 100644 --- a/messages/en.json +++ b/messages/en.json @@ -528,7 +528,7 @@ "loading": "Checking your link…", "invalid": "This link no longer works", "invalidHint": "It is unknown, expired, withdrawn or already used. Ask the person who sent it for a new one.", - "oidcOnly": "This server only allows single sign-on, so a profile cannot be taken over here yet.", + "oidcOnly": "This server only allows single sign-on. Continue with your identity provider instead.", "signedInTitle": "Sign out first", "signedInBody": "You are signed in as {username}. This link sets up a sign-in for someone else, so sign out before you use it.", "signOut": "Sign out and continue", @@ -545,7 +545,19 @@ "errorTaken": "That username or email address is already in use.", "errorRateLimit": "Too many attempts. Wait a few minutes and try again.", "errorFailed": "That did not work. Try again in a moment.", - "errorOffline": "That did not reach the server. Check your connection and try again." + "errorOffline": "That did not reach the server. Check your connection and try again.", + "ssoContinue": "Continue with {provider}", + "ssoStarting": "Redirecting…", + "ssoHint": "From then on you sign in here with your {provider} account.", + "ssoDivider": "or", + "ssoFallbackName": "single sign-on", + "errorTitle": "The handover did not go through", + "errorSubjectBound": "This sign-in already belongs to another account on this server. Open the link again and use a different account at your identity provider.", + "errorEmailTaken": "Your identity provider gave an email address that another account here already uses. Open the link again and use a different account, or ask the server's administrator.", + "errorUsernameTaken": "Someone took that username in the meantime. Open the link again and choose another one.", + "errorSignedIn": "You were signed in when you came back. Sign out, then open the link again.", + "errorSso": "Signing in with your identity provider did not work. Open the link again to try once more.", + "signInInstead": "Sign in to that account" }, "inviteInvalid": { "title": "Invitation invalid", @@ -11900,9 +11912,11 @@ "revokeFailed": "The link could not be withdrawn. Try again.", "errorFailed": "The handover link could not be created.", "errorRosterChanged": "The list of guardians changed in the meantime. Close this and open it again.", - "oidcOnly": "Handing over a profile is not available while this server allows single sign-on only.", + "oidcOnly": "Handing over a profile is not available on this server.", "loadError": "The handover status could not be loaded.", - "entryHint": "When {name} is ready to look after this record themselves." + "entryHint": "When {name} is ready to look after this record themselves.", + "methodSso": "{name} signs in through single sign-on when they use the link.", + "methodBoth": "{name} can set a password or sign in through single sign-on." } }, "toast": { diff --git a/messages/es.json b/messages/es.json index 8a5ba0bf7..312bea657 100644 --- a/messages/es.json +++ b/messages/es.json @@ -528,7 +528,7 @@ "loading": "Comprobando tu enlace…", "invalid": "Este enlace ya no funciona", "invalidHint": "Es desconocido, ha caducado, se ha retirado o ya se ha usado. Pide uno nuevo a quien te lo envió.", - "oidcOnly": "Este servidor solo permite el inicio de sesión único, así que aquí todavía no se puede asumir un perfil.", + "oidcOnly": "Este servidor solo permite el inicio de sesión único. Continúa con tu proveedor de identidad.", "signedInTitle": "Cierra la sesión primero", "signedInBody": "Has iniciado sesión como {username}. Este enlace crea un acceso para otra persona, así que cierra la sesión antes de usarlo.", "signOut": "Cerrar sesión y continuar", @@ -545,7 +545,19 @@ "errorTaken": "Ese nombre de usuario o correo electrónico ya está en uso.", "errorRateLimit": "Demasiados intentos. Espera unos minutos y vuelve a intentarlo.", "errorFailed": "No ha funcionado. Vuelve a intentarlo en un momento.", - "errorOffline": "No se llegó al servidor. Comprueba tu conexión y vuelve a intentarlo." + "errorOffline": "No se llegó al servidor. Comprueba tu conexión y vuelve a intentarlo.", + "ssoContinue": "Continuar con {provider}", + "ssoStarting": "Redirigiendo…", + "ssoHint": "A partir de entonces inicias sesión aquí con tu cuenta de {provider}.", + "ssoDivider": "o", + "ssoFallbackName": "inicio de sesión único", + "errorTitle": "La entrega no se ha completado", + "errorSubjectBound": "Este inicio de sesión ya pertenece a otra cuenta de este servidor. Vuelve a abrir el enlace y usa otra cuenta en tu proveedor de identidad.", + "errorEmailTaken": "Tu proveedor de identidad ha enviado una dirección de correo que ya usa otra cuenta aquí. Vuelve a abrir el enlace y usa otra cuenta, o pregunta a quien administra el servidor.", + "errorUsernameTaken": "Alguien ha cogido ese nombre de usuario mientras tanto. Vuelve a abrir el enlace y elige otro.", + "errorSignedIn": "Tenías una sesión iniciada al volver. Cierra la sesión y vuelve a abrir el enlace.", + "errorSso": "No se ha podido iniciar sesión con tu proveedor de identidad. Vuelve a abrir el enlace para intentarlo otra vez.", + "signInInstead": "Iniciar sesión en esa cuenta" }, "inviteInvalid": { "title": "Invitación no válida", @@ -11900,9 +11912,11 @@ "revokeFailed": "No se pudo retirar el enlace. Vuelve a intentarlo.", "errorFailed": "No se pudo crear el enlace de entrega.", "errorRosterChanged": "Mientras tanto ha cambiado quién se ocupa del historial. Cierra esto y vuelve a abrirlo.", - "oidcOnly": "No se puede entregar un perfil mientras este servidor solo permita el inicio de sesión único.", + "oidcOnly": "En este servidor no se puede entregar un perfil.", "loadError": "No se pudo cargar el estado de la entrega.", - "entryHint": "Cuando {name} quiera llevar este historial por su cuenta." + "entryHint": "Cuando {name} quiera llevar este historial por su cuenta.", + "methodSso": "{name} inicia sesión con el inicio de sesión único cuando usa el enlace.", + "methodBoth": "{name} puede elegir una contraseña o iniciar sesión con el inicio de sesión único." } }, "toast": { diff --git a/messages/fr.json b/messages/fr.json index 5d3bd5fd1..7302f9fc3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -528,7 +528,7 @@ "loading": "Vérification de votre lien…", "invalid": "Ce lien ne fonctionne plus", "invalidHint": "Il est inconnu, expiré, retiré ou déjà utilisé. Demandez-en un nouveau à la personne qui vous l'a envoyé.", - "oidcOnly": "Ce serveur n'autorise que l'authentification unique, un profil ne peut donc pas encore être repris ici.", + "oidcOnly": "Ce serveur n'autorise que l'authentification unique. Continuez plutôt avec votre fournisseur d'identité.", "signedInTitle": "Déconnectez-vous d'abord", "signedInBody": "Vous êtes connecté en tant que {username}. Ce lien crée une connexion pour quelqu'un d'autre, déconnectez-vous donc avant de l'utiliser.", "signOut": "Se déconnecter et continuer", @@ -545,7 +545,19 @@ "errorTaken": "Ce nom d'utilisateur ou cette adresse e-mail est déjà utilisé.", "errorRateLimit": "Trop de tentatives. Attendez quelques minutes puis réessayez.", "errorFailed": "Cela n'a pas fonctionné. Réessayez dans un instant.", - "errorOffline": "Le serveur n'a pas été atteint. Vérifiez votre connexion et réessayez." + "errorOffline": "Le serveur n'a pas été atteint. Vérifiez votre connexion et réessayez.", + "ssoContinue": "Continuer avec {provider}", + "ssoStarting": "Redirection…", + "ssoHint": "Ensuite, vous vous connectez ici avec votre compte {provider}.", + "ssoDivider": "ou", + "ssoFallbackName": "l'authentification unique", + "errorTitle": "La transmission n'a pas abouti", + "errorSubjectBound": "Cette connexion appartient déjà à un autre compte sur ce serveur. Rouvrez le lien et utilisez un autre compte chez votre fournisseur d'identité.", + "errorEmailTaken": "Votre fournisseur d'identité a transmis une adresse e-mail déjà utilisée par un autre compte ici. Rouvrez le lien et utilisez un autre compte, ou contactez la personne qui administre le serveur.", + "errorUsernameTaken": "Quelqu'un a pris ce nom d'utilisateur entre-temps. Rouvrez le lien et choisissez-en un autre.", + "errorSignedIn": "Vous étiez connecté à votre retour. Déconnectez-vous, puis rouvrez le lien.", + "errorSso": "La connexion avec votre fournisseur d'identité n'a pas fonctionné. Rouvrez le lien pour réessayer.", + "signInInstead": "Se connecter à ce compte" }, "inviteInvalid": { "title": "Invitation invalide", @@ -11900,9 +11912,11 @@ "revokeFailed": "Le lien n'a pas pu être retiré. Réessayez.", "errorFailed": "Le lien de transmission n'a pas pu être créé.", "errorRosterChanged": "Entre-temps, les personnes qui s'occupent du dossier ont changé. Fermez ceci et rouvrez-le.", - "oidcOnly": "Transmettre un profil n'est pas possible tant que ce serveur n'autorise que l'authentification unique.", + "oidcOnly": "Transmettre un profil n'est pas possible sur ce serveur.", "loadError": "L'état de la transmission n'a pas pu être chargé.", - "entryHint": "Quand {name} souhaite tenir ce dossier de façon autonome." + "entryHint": "Quand {name} souhaite tenir ce dossier de façon autonome.", + "methodSso": "{name} se connecte par authentification unique en utilisant le lien.", + "methodBoth": "{name} peut définir un mot de passe ou se connecter par authentification unique." } }, "toast": { diff --git a/messages/it.json b/messages/it.json index adaf75f83..c9a714b6e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -528,7 +528,7 @@ "loading": "Controllo del link…", "invalid": "Questo link non funziona più", "invalidHint": "È sconosciuto, scaduto, ritirato o già usato. Chiedine uno nuovo a chi te l'ha inviato.", - "oidcOnly": "Questo server consente solo il single sign-on, quindi qui non è ancora possibile prendere in carico un profilo.", + "oidcOnly": "Questo server consente solo il single sign-on. Continua invece con il tuo provider di identità.", "signedInTitle": "Prima esci", "signedInBody": "Hai effettuato l'accesso come {username}. Questo link crea un accesso per un'altra persona, quindi esci prima di usarlo.", "signOut": "Esci e continua", @@ -545,7 +545,19 @@ "errorTaken": "Questo nome utente o indirizzo email è già in uso.", "errorRateLimit": "Troppi tentativi. Attendi qualche minuto e riprova.", "errorFailed": "Non ha funzionato. Riprova tra un momento.", - "errorOffline": "Il server non è stato raggiunto. Controlla la connessione e riprova." + "errorOffline": "Il server non è stato raggiunto. Controlla la connessione e riprova.", + "ssoContinue": "Continua con {provider}", + "ssoStarting": "Reindirizzamento…", + "ssoHint": "Da quel momento accedi qui con il tuo account {provider}.", + "ssoDivider": "oppure", + "ssoFallbackName": "single sign-on (SSO)", + "errorTitle": "La consegna non è andata a buon fine", + "errorSubjectBound": "Questo accesso appartiene già a un altro account su questo server. Riapri il link e usa un altro account presso il tuo provider di identità.", + "errorEmailTaken": "Il tuo provider di identità ha fornito un indirizzo email che qui usa già un altro account. Riapri il link e usa un altro account, oppure chiedi a chi amministra il server.", + "errorUsernameTaken": "Nel frattempo qualcuno ha preso quel nome utente. Riapri il link e scegline un altro.", + "errorSignedIn": "Al ritorno avevi già effettuato l'accesso. Esci, poi riapri il link.", + "errorSso": "L'accesso con il tuo provider di identità non è riuscito. Riapri il link per riprovare.", + "signInInstead": "Accedi a quell'account" }, "inviteInvalid": { "title": "Invito non valido", @@ -11900,9 +11912,11 @@ "revokeFailed": "Impossibile ritirare il link. Riprova.", "errorFailed": "Impossibile creare il link di consegna.", "errorRosterChanged": "Nel frattempo è cambiato chi si occupa della cartella. Chiudi e riapri.", - "oidcOnly": "Non è possibile consegnare un profilo finché questo server consente solo il single sign-on.", + "oidcOnly": "Su questo server non è possibile consegnare un profilo.", "loadError": "Impossibile caricare lo stato della consegna.", - "entryHint": "Quando {name} vuole tenere questa cartella in autonomia." + "entryHint": "Quando {name} vuole tenere questa cartella in autonomia.", + "methodSso": "{name} accede con il single sign-on quando usa il link.", + "methodBoth": "{name} può impostare una password oppure accedere con il single sign-on." } }, "toast": { diff --git a/messages/ko.json b/messages/ko.json index e958a666a..0c8f8a915 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -528,7 +528,7 @@ "loading": "링크를 확인하는 중…", "invalid": "이 링크는 더 이상 작동하지 않습니다", "invalidHint": "알 수 없거나, 만료되었거나, 철회되었거나, 이미 사용된 링크입니다. 보낸 사람에게 새 링크를 요청하세요.", - "oidcOnly": "이 서버는 통합 로그인(SSO)만 허용하므로 아직 여기서 프로필을 넘겨받을 수 없습니다.", + "oidcOnly": "이 서버는 통합 로그인(SSO)만 허용합니다. 대신 ID 공급자로 계속하세요.", "signedInTitle": "먼저 로그아웃하세요", "signedInBody": "{username} 계정으로 로그인되어 있습니다. 이 링크는 다른 사람의 로그인을 만드는 링크이므로 사용하기 전에 로그아웃하세요.", "signOut": "로그아웃하고 계속", @@ -545,7 +545,19 @@ "errorTaken": "이미 사용 중인 사용자 이름 또는 이메일 주소입니다.", "errorRateLimit": "시도 횟수가 너무 많습니다. 몇 분 후 다시 시도하세요.", "errorFailed": "처리하지 못했습니다. 잠시 후 다시 시도하세요.", - "errorOffline": "서버에 연결하지 못했습니다. 연결을 확인하고 다시 시도하세요." + "errorOffline": "서버에 연결하지 못했습니다. 연결을 확인하고 다시 시도하세요.", + "ssoContinue": "{provider}(으)로 계속", + "ssoStarting": "이동하는 중…", + "ssoHint": "이후에는 {provider} 계정으로 여기에 로그인합니다.", + "ssoDivider": "또는", + "ssoFallbackName": "통합 로그인(SSO)", + "errorTitle": "넘겨받기를 완료하지 못했습니다", + "errorSubjectBound": "이 로그인은 이미 이 서버의 다른 계정에 연결되어 있습니다. 링크를 다시 열고 ID 공급자에서 다른 계정을 사용하세요.", + "errorEmailTaken": "ID 공급자가 보낸 이메일 주소를 이 서버의 다른 계정이 이미 사용하고 있습니다. 링크를 다시 열고 다른 계정을 사용하거나 서버 관리자에게 문의하세요.", + "errorUsernameTaken": "그 사이에 다른 사람이 이 사용자 이름을 사용했습니다. 링크를 다시 열고 다른 이름을 선택하세요.", + "errorSignedIn": "돌아왔을 때 이미 로그인되어 있었습니다. 로그아웃한 다음 링크를 다시 여세요.", + "errorSso": "ID 공급자로 로그인하지 못했습니다. 링크를 다시 열어 한 번 더 시도하세요.", + "signInInstead": "그 계정으로 로그인" }, "inviteInvalid": { "title": "유효하지 않은 초대예요", @@ -11900,9 +11912,11 @@ "revokeFailed": "링크를 철회하지 못했습니다. 다시 시도하세요.", "errorFailed": "넘겨주기 링크를 만들지 못했습니다.", "errorRosterChanged": "그 사이 기록을 돌보는 사람이 바뀌었습니다. 닫았다가 다시 여세요.", - "oidcOnly": "이 서버가 통합 로그인(SSO)만 허용하는 동안에는 프로필을 넘겨줄 수 없습니다.", + "oidcOnly": "이 서버에서는 프로필을 넘겨줄 수 없습니다.", "loadError": "넘겨주기 상태를 불러오지 못했습니다.", - "entryHint": "{name} 님이 이 기록을 직접 관리할 준비가 되었을 때." + "entryHint": "{name} 님이 이 기록을 직접 관리할 준비가 되었을 때.", + "methodSso": "{name}님은 링크를 사용할 때 통합 로그인(SSO)으로 로그인합니다.", + "methodBoth": "{name}님은 비밀번호를 정하거나 통합 로그인(SSO)으로 로그인할 수 있습니다." } }, "toast": { diff --git a/messages/pl.json b/messages/pl.json index 4cf8b75a8..da6ada07c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -528,7 +528,7 @@ "loading": "Sprawdzanie linku…", "invalid": "Ten link już nie działa", "invalidHint": "Jest nieznany, wygasł, został wycofany albo już użyty. Poproś osobę, która go wysłała, o nowy.", - "oidcOnly": "Ten serwer pozwala tylko na logowanie jednokrotne (SSO), więc profilu nie można tu jeszcze przejąć.", + "oidcOnly": "Ten serwer pozwala tylko na logowanie jednokrotne (SSO). Kontynuuj u swojego dostawcy tożsamości.", "signedInTitle": "Najpierw się wyloguj", "signedInBody": "Masz zalogowaną sesję jako {username}. Ten link tworzy logowanie dla kogoś innego, więc wyloguj się, zanim go użyjesz.", "signOut": "Wyloguj i kontynuuj", @@ -545,7 +545,19 @@ "errorTaken": "Ta nazwa użytkownika lub adres e-mail jest już zajęty.", "errorRateLimit": "Zbyt wiele prób. Odczekaj kilka minut i spróbuj ponownie.", "errorFailed": "Nie udało się. Spróbuj ponownie za chwilę.", - "errorOffline": "Nie udało się połączyć z serwerem. Sprawdź połączenie i spróbuj ponownie." + "errorOffline": "Nie udało się połączyć z serwerem. Sprawdź połączenie i spróbuj ponownie.", + "ssoContinue": "Kontynuuj przez {provider}", + "ssoStarting": "Przekierowywanie…", + "ssoHint": "Od tej pory logujesz się tutaj swoim kontem {provider}.", + "ssoDivider": "lub", + "ssoFallbackName": "logowanie jednokrotne (SSO)", + "errorTitle": "Przejęcie się nie udało", + "errorSubjectBound": "To logowanie należy już do innego konta na tym serwerze. Otwórz link ponownie i użyj innego konta u swojego dostawcy tożsamości.", + "errorEmailTaken": "Twój dostawca tożsamości przekazał adres e-mail, którego używa już inne konto na tym serwerze. Otwórz link ponownie i użyj innego konta albo zapytaj osobę, która administruje serwerem.", + "errorUsernameTaken": "W międzyczasie ktoś zajął tę nazwę użytkownika. Otwórz link ponownie i wybierz inną.", + "errorSignedIn": "Po powrocie byłeś zalogowany. Wyloguj się i otwórz link ponownie.", + "errorSso": "Logowanie u dostawcy tożsamości się nie powiodło. Otwórz link ponownie, aby spróbować jeszcze raz.", + "signInInstead": "Zaloguj się na to konto" }, "inviteInvalid": { "title": "Nieprawidłowe zaproszenie", @@ -11900,9 +11912,11 @@ "revokeFailed": "Nie udało się wycofać linku. Spróbuj ponownie.", "errorFailed": "Nie udało się utworzyć linku przekazania.", "errorRosterChanged": "W międzyczasie zmieniło się, kto opiekuje się kartą. Zamknij to i otwórz ponownie.", - "oidcOnly": "Przekazanie profilu nie jest możliwe, dopóki ten serwer pozwala tylko na logowanie jednokrotne (SSO).", + "oidcOnly": "Przekazanie profilu nie jest możliwe na tym serwerze.", "loadError": "Nie udało się wczytać stanu przekazania.", - "entryHint": "Gdy {name} chce samodzielnie prowadzić tę kartę." + "entryHint": "Gdy {name} chce samodzielnie prowadzić tę kartę.", + "methodSso": "{name} loguje się przez logowanie jednokrotne (SSO), gdy użyje linku.", + "methodBoth": "{name} może ustawić hasło albo zalogować się przez logowanie jednokrotne (SSO)." } }, "toast": { diff --git a/src/app/auth/claim/page.tsx b/src/app/auth/claim/page.tsx index 217302b9c..2a37b693f 100644 --- a/src/app/auth/claim/page.tsx +++ b/src/app/auth/claim/page.tsx @@ -6,7 +6,9 @@ import { ClaimProfile } from "@/components/auth/claim-profile"; /** * v1.42 (#959) — `/auth/claim?token=hlp_…`, where the link a Guardian handed - * over (`/claim/`) lands after the edge redirect. + * over (`/claim/`) lands after the edge redirect. Since v1.43 also + * `/auth/claim?error=`, where a refused single-sign-on claim returns; + * the reason is a fixed word, never a credential. * * A URL-credential surface by shape (`session-surface-guard.test.ts`, S2): the * page reads the one-time token off the query string and passes it to the @@ -16,5 +18,10 @@ import { ClaimProfile } from "@/components/auth/claim-profile"; */ export default function ClaimProfilePage() { const searchParams = useSearchParams(); - return ; + return ( + + ); } diff --git a/src/components/auth/__tests__/claim-profile-invalid.test.tsx b/src/components/auth/__tests__/claim-profile-invalid.test.tsx index b240065c0..5e0881043 100644 --- a/src/components/auth/__tests__/claim-profile-invalid.test.tsx +++ b/src/components/auth/__tests__/claim-profile-invalid.test.tsx @@ -19,6 +19,7 @@ vi.mock("@/hooks/use-auth", () => ({ vi.mock("@/lib/queries/use-profile-claim", () => ({ useProfileClaimPreview: () => ({ data: undefined, error: null }), useClaimProfile: () => ({ mutate: () => {}, isPending: false }), + useClaimProfileSso: () => ({ mutate: () => {}, isPending: false }), })); import { ClaimProfile } from "../claim-profile"; diff --git a/src/components/auth/__tests__/claim-profile-sso.test.tsx b/src/components/auth/__tests__/claim-profile-sso.test.tsx new file mode 100644 index 000000000..75d1c8d99 --- /dev/null +++ b/src/components/auth/__tests__/claim-profile-sso.test.tsx @@ -0,0 +1,105 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { ProfileClaimPreview } from "@/lib/queries/use-profile-claim"; + +/** + * v1.43 (#959) — the claim page offers the ways in the server names: the + * password form, the identity provider, or both. A refused single-sign-on + * claim comes back with `?error=` and no link, and says what happened. + */ + +let previewData: ProfileClaimPreview | undefined; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: () => {}, refresh: () => {}, push: () => {} }), +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: null }), + clearCachesForSessionEnd: () => {}, +})); +vi.mock("@/lib/queries/use-profile-claim", () => ({ + useProfileClaimPreview: () => ({ data: previewData, error: null }), + useClaimProfile: () => ({ mutate: () => {}, isPending: false }), + useClaimProfileSso: () => ({ mutate: () => {}, isPending: false }), +})); + +import { ClaimProfile, claimSsoErrorKey } from "../claim-profile"; + +const BASE: ProfileClaimPreview = { + displayName: "Robin", + expiresAt: "2030-01-01T00:00:00.000Z", + guardians: [], +}; + +function render(props: { token: string | null; error?: string | null }) { + return renderToStaticMarkup( + + + + + , + ); +} + +describe("ClaimProfile — ways in", () => { + it("offers only the provider on a single-sign-on-only server", () => { + previewData = { ...BASE, methods: ["sso"], ssoProviderName: "Example ID" }; + const html = render({ token: "hlp_x" }); + expect(html).toContain('data-slot="claim-sso-continue"'); + expect(html).toContain("Continue with Example ID"); + expect(html).not.toContain('id="claim-password"'); + expect(html).not.toContain('id="claim-email"'); + expect(html).not.toContain('data-slot="claim-submit"'); + expect(html).toContain('id="claim-username"'); + }); + + it("offers both when both are allowed", () => { + previewData = { + ...BASE, + methods: ["password", "sso"], + ssoProviderName: "Example ID", + }; + const html = render({ token: "hlp_x" }); + expect(html).toContain('data-slot="claim-sso-continue"'); + expect(html).toContain('id="claim-password"'); + expect(html).toContain('data-slot="claim-submit"'); + }); + + it("offers only the password when an older server names no methods", () => { + previewData = { ...BASE }; + const html = render({ token: "hlp_x" }); + expect(html).not.toContain('data-slot="claim-sso-continue"'); + expect(html).toContain('id="claim-password"'); + }); +}); + +describe("ClaimProfile — a refused single-sign-on claim", () => { + it("says what happened and offers a way on", () => { + previewData = undefined; + const html = render({ token: null, error: "claim_sso_subject_bound" }); + expect(html).toContain('data-testid="claim-sso-error"'); + expect(html).toContain("already belongs to another account"); + expect(html).toContain('href="/auth/login"'); + }); + + it("maps every reason the callback sends, and reads an unknown one as a failure", () => { + expect(claimSsoErrorKey("claim_expired")).toBe("auth.claim.invalidHint"); + expect(claimSsoErrorKey("claim_sso_subject_bound")).toBe( + "auth.claim.errorSubjectBound", + ); + expect(claimSsoErrorKey("claim_email_taken")).toBe( + "auth.claim.errorEmailTaken", + ); + expect(claimSsoErrorKey("claim_username_taken")).toBe( + "auth.claim.errorUsernameTaken", + ); + expect(claimSsoErrorKey("claim_signed_in")).toBe( + "auth.claim.errorSignedIn", + ); + expect(claimSsoErrorKey("oidc_failed")).toBe("auth.claim.errorSso"); + expect(claimSsoErrorKey("