mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
This commit is contained in:
1 parent
5dab12472f
commit
9eae352bb1
20 files changed
+1075
-208
No files matched your search
+137
-121
@@ -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=<reason>` 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=<reason>` (browser) or
|
||||
description: Always a redirect. To the provider on success. On failure to `/auth/login?error=<reason>` (browser;
|
||||
`/auth/claim?error=<reason>` with `intent=claim`, where `claim_expired` is one more reason) or
|
||||
`healthlog://oidc-callback?error=<reason>` (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: <json>\\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;
|
||||
|
||||
@@ -37,7 +37,6 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
|
||||
"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<Record<string, readonly string[]>> = {
|
||||
};
|
||||
|
||||
/** 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"] })
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"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
|
||||
|
||||
@@ -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<unknown> {
|
||||
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<string, unknown> };
|
||||
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" } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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=<reason>` in both.
|
||||
// page otherwise, and the claim page for a claim. Machine-readable
|
||||
// `error=<reason>` 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, {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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 });
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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<SsoClaimRefusal | null> {
|
||||
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<SsoClaimOutcome> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<HandoverPreview | null> {
|
||||
const row = await findHandoverByToken(prisma, rawToken);
|
||||
async function usableHandover(
|
||||
row: Awaited<ReturnType<typeof findHandoverByToken>>,
|
||||
now: Date,
|
||||
): Promise<{
|
||||
row: NonNullable<Awaited<ReturnType<typeof findHandoverByToken>>>;
|
||||
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<HandoverPreview | null> {
|
||||
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<boolean> {
|
||||
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<ClaimedProfile> {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -350,6 +350,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
profile_claim: [
|
||||
"profile_claim.invalid",
|
||||
"profile_claim.oidc_only_unsupported",
|
||||
"profile_claim.sso_unavailable",
|
||||
"profile_claim.taken",
|
||||
],
|
||||
record_write: ["record_write.rate_limited"],
|
||||
|
||||
@@ -736,7 +736,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"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": {
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
} from "@/lib/validations/step-up";
|
||||
import {
|
||||
dataEnvelope,
|
||||
notImplementedResponse,
|
||||
profileEmailRateLimitResponse,
|
||||
stdResponses,
|
||||
errorEnvelope,
|
||||
@@ -768,7 +767,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
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=<reason>` 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<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -807,7 +805,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
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<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"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<ZodOpenApiObject["paths"]> = {
|
||||
.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<ZodOpenApiObject["paths"]> = {
|
||||
responses: {
|
||||
"302": {
|
||||
description:
|
||||
"Always a redirect. To the provider on success. On failure to `/auth/login?error=<reason>` (browser) or `healthlog://oidc-callback?error=<reason>` (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=<hlh_…>`, with `mfa_ticket=<ticket>&methods=<list>`, or with `error=<reason>`, 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=<reason>` (browser; `/auth/claim?error=<reason>` with `intent=claim`, where `claim_expired` is one more reason) or `healthlog://oidc-callback?error=<reason>` (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=<hlh_…>`, with `mfa_ticket=<ticket>&methods=<list>`, or with `error=<reason>`, 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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -15,3 +15,4 @@ export type ClaimSsoInput = z.infer<typeof claimSsoSchema>;
|
||||
|
||||
/** 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];
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user