Merge branch 'v143/sso' into release/v1.43.0

# Conflicts:
#	docs/api/openapi.yaml
#	src/__tests__/contract-stub-inventory.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 23:59:03 +02:00
commit 5c039a220b
39 files changed
+2649 -365

No files matched your search

+194 -178
View File
File diff suppressed because it is too large. Load diff
+52 -5
View File
@@ -231,11 +231,58 @@ as something to log in as.
person it was sent to, so the admin notification diagnostic can answer
"who was this for" and "whose record was it about" separately.
There is no emancipation flow: no route, no control and no copy suggests
a managed profile can be turned into an ordinary account. The data model
leaves room for it (the credentials are nullable and the managed marker
can be cleared) and clearing that marker stops the notification fan-out,
but nothing in v1.37.0 exercises any of that.
### Handing a record over to its person
Since v1.42 a guardian can hand a managed profile over to the person it
describes, so the record becomes their own account with its whole history.
Nothing is copied or moved; the profile's row gets credentials, loses the
managed marker, and stops fanning reminders out to its guardians.
- A guardian with a fresh second factor creates a one-time link (valid for
one, seven or fourteen days, shown once) and proposes, for each guardian,
no access, view only or manage. Only a keyed hash of the link is stored,
at most one link per profile is open, and any guardian can withdraw it
without a second factor.
- The person opens the link, sees the record's name, the expiry and who
keeps which access, and chooses how they sign in. The claim runs as one
transaction: the link is consumed, the proposals are applied, open
invitations the record offered are withdrawn, and the disclaimer,
onboarding and AI consent are reset so the person gives them themselves.
After the first sign-in they confirm or change each guardian's access.
- How the person signs in depends on the instance:
| Instance | Ways in |
| --------------------------------- | -------------------------- |
| No SSO configured | Password |
| SSO configured, `OIDC_ONLY` unset | Password or single sign-on |
| `OIDC_ONLY="true"` | Single sign-on only |
The guardian's panel and the claim page both show which ways are open.
- **Through single sign-on** (v1.43) the person picks a username and
continues to the identity provider. When they come back, the provider's
`(issuer, sub)` pair is bound to the record in the same transaction, the
address is taken from the provider only when it asserts
`email_verified: true` (otherwise the account has no address), and no
password is set. Refused, with the record left exactly as it was:
a provider identity that already signs in to an account on this instance
(one identity, one account; the refusal is audited on that account as
`auth.oidc.claim_conflict`), a verified address another account holds,
a username taken in the meantime, a link that was used, withdrawn,
replaced or expired while the person was at the provider, and a browser
that is signed in to another account.
- The link never appears in a URL the server builds: the claim page sends it
in request bodies only, and the single-sign-on leg carries a sealed,
ten-minute, HttpOnly cookie that names the link by its id. The `/claim/`
path and `hlp_` tokens are scrubbed from logs.
- The iOS app does not claim. The person claims in a browser and then signs
in to the app as usual (with SSO, through the app's own SSO sign-in).
Audit rows: `managed_profile.handover.created` and `.revoked` on the record,
`managed_profile.claim_sso.started` when a single-sign-on claim starts,
`managed_profile.claimed` and `auth.claim` (with `method: "password"` or
`"sso"`) when it lands, and `managed_profile.handed_over` on each guardian's
own account.
## Seeing what happened
+10
View File
@@ -112,6 +112,12 @@ Client registration essentials only — the IdP's own docs cover the rest.
MFA in the IdP's policy engine — that is the point of delegating
authentication. HealthLog-side 2FA remains available on top for the
step-up gates above.
- **Claiming a managed profile binds a fresh identity only.** When a
person claims a managed profile through SSO, their `(issuer, sub)` is
bound to that record, and only if no account on the instance already
holds it, and the provider's address is used only when it is verified
and not already taken. A claim can therefore never attach somebody's
existing sign-in to a second record or take over an existing account.
- **Linking is once.** There is no unlink/relink flow in this cut; the
pin is deliberately sticky. Moving an account to a new IdP identity is
an operator decision (a manual `users.oidc_issuer` / `users.oidc_sub`
@@ -134,6 +140,10 @@ Consequences to accept before turning it on:
- **Self-disabling on half-config.** The flag only takes effect while
the three provider vars are fully set, so a typo in the provider group
can never lock every user out.
- **Managed profiles are still handed over** (v1.43). The person claims the
record through the identity provider instead of setting a password; the
password claim is refused. See
[Handing a record over to its person](account-sharing.md#handing-a-record-over-to-its-person).
### Break-glass (IdP down or misconfigured)
+18 -4
View File
@@ -528,7 +528,7 @@
"loading": "Dein Link wird geprüft…",
"invalid": "Dieser Link funktioniert nicht mehr",
"invalidHint": "Er ist unbekannt, abgelaufen, zurückgezogen oder schon benutzt. Bitte die Person, die ihn dir gegeben hat, um einen neuen.",
"oidcOnly": "Dieser Server erlaubt nur Single Sign-on, deshalb lässt sich ein Profil hier noch nicht übernehmen.",
"oidcOnly": "Dieser Server erlaubt nur Single Sign-on. Fahre stattdessen mit deinem Identitätsanbieter fort.",
"signedInTitle": "Melde dich zuerst ab",
"signedInBody": "Du bist als {username} angemeldet. Dieser Link richtet eine Anmeldung für jemand anderen ein, melde dich also ab, bevor du ihn benutzt.",
"signOut": "Abmelden und weiter",
@@ -545,7 +545,19 @@
"errorTaken": "Dieser Benutzername oder diese E-Mail-Adresse ist schon vergeben.",
"errorRateLimit": "Zu viele Versuche. Warte ein paar Minuten und versuche es erneut.",
"errorFailed": "Das hat nicht geklappt. Versuche es gleich noch einmal.",
"errorOffline": "Das hat den Server nicht erreicht. Prüfe deine Verbindung und versuche es erneut."
"errorOffline": "Das hat den Server nicht erreicht. Prüfe deine Verbindung und versuche es erneut.",
"ssoContinue": "Weiter mit {provider}",
"ssoStarting": "Weiterleitung…",
"ssoHint": "Ab dann meldest du dich hier mit deinem Konto bei {provider} an.",
"ssoDivider": "oder",
"ssoFallbackName": "Single Sign-on",
"errorTitle": "Die Übernahme hat nicht geklappt",
"errorSubjectBound": "Diese Anmeldung gehört schon zu einem anderen Konto auf diesem Server. Öffne den Link noch einmal und nimm ein anderes Konto bei deinem Identitätsanbieter.",
"errorEmailTaken": "Dein Identitätsanbieter hat eine E-Mail-Adresse geliefert, die hier schon ein anderes Konto nutzt. Öffne den Link noch einmal und nimm ein anderes Konto, oder frag die Person, die den Server betreibt.",
"errorUsernameTaken": "Jemand hat diesen Benutzernamen inzwischen vergeben. Öffne den Link noch einmal und wähle einen anderen.",
"errorSignedIn": "Bei der Rückkehr warst du angemeldet. Melde dich ab und öffne den Link noch einmal.",
"errorSso": "Die Anmeldung bei deinem Identitätsanbieter hat nicht funktioniert. Öffne den Link noch einmal, um es erneut zu versuchen.",
"signInInstead": "Bei diesem Konto anmelden"
},
"inviteInvalid": {
"title": "Einladung ungültig",
@@ -11989,9 +12001,11 @@
"revokeFailed": "Der Link ließ sich nicht zurückziehen. Versuche es erneut.",
"errorFailed": "Der Übergabelink ließ sich nicht erstellen.",
"errorRosterChanged": "Inzwischen hat sich geändert, wer die Akte betreut. Schließe das hier und öffne es erneut.",
"oidcOnly": "Ein Profil zu übergeben ist nicht möglich, solange dieser Server nur Single Sign-on erlaubt.",
"oidcOnly": "Ein Profil zu übergeben ist auf diesem Server nicht möglich.",
"loadError": "Der Stand der Übergabe ließ sich nicht laden.",
"entryHint": "Wenn {name} die Akte selbst führen möchte."
"entryHint": "Wenn {name} die Akte selbst führen möchte.",
"methodSso": "{name} meldet sich beim Einlösen des Links über Single Sign-on an.",
"methodBoth": "{name} kann ein Passwort festlegen oder sich über Single Sign-on anmelden."
}
},
"toast": {
+18 -4
View File
@@ -528,7 +528,7 @@
"loading": "Checking your link…",
"invalid": "This link no longer works",
"invalidHint": "It is unknown, expired, withdrawn or already used. Ask the person who sent it for a new one.",
"oidcOnly": "This server only allows single sign-on, so a profile cannot be taken over here yet.",
"oidcOnly": "This server only allows single sign-on. Continue with your identity provider instead.",
"signedInTitle": "Sign out first",
"signedInBody": "You are signed in as {username}. This link sets up a sign-in for someone else, so sign out before you use it.",
"signOut": "Sign out and continue",
@@ -545,7 +545,19 @@
"errorTaken": "That username or email address is already in use.",
"errorRateLimit": "Too many attempts. Wait a few minutes and try again.",
"errorFailed": "That did not work. Try again in a moment.",
"errorOffline": "That did not reach the server. Check your connection and try again."
"errorOffline": "That did not reach the server. Check your connection and try again.",
"ssoContinue": "Continue with {provider}",
"ssoStarting": "Redirecting…",
"ssoHint": "From then on you sign in here with your {provider} account.",
"ssoDivider": "or",
"ssoFallbackName": "single sign-on",
"errorTitle": "The handover did not go through",
"errorSubjectBound": "This sign-in already belongs to another account on this server. Open the link again and use a different account at your identity provider.",
"errorEmailTaken": "Your identity provider gave an email address that another account here already uses. Open the link again and use a different account, or ask the server's administrator.",
"errorUsernameTaken": "Someone took that username in the meantime. Open the link again and choose another one.",
"errorSignedIn": "You were signed in when you came back. Sign out, then open the link again.",
"errorSso": "Signing in with your identity provider did not work. Open the link again to try once more.",
"signInInstead": "Sign in to that account"
},
"inviteInvalid": {
"title": "Invitation invalid",
@@ -11989,9 +12001,11 @@
"revokeFailed": "The link could not be withdrawn. Try again.",
"errorFailed": "The handover link could not be created.",
"errorRosterChanged": "The list of guardians changed in the meantime. Close this and open it again.",
"oidcOnly": "Handing over a profile is not available while this server allows single sign-on only.",
"oidcOnly": "Handing over a profile is not available on this server.",
"loadError": "The handover status could not be loaded.",
"entryHint": "When {name} is ready to look after this record themselves."
"entryHint": "When {name} is ready to look after this record themselves.",
"methodSso": "{name} signs in through single sign-on when they use the link.",
"methodBoth": "{name} can set a password or sign in through single sign-on."
}
},
"toast": {
+18 -4
View File
@@ -528,7 +528,7 @@
"loading": "Comprobando tu enlace…",
"invalid": "Este enlace ya no funciona",
"invalidHint": "Es desconocido, ha caducado, se ha retirado o ya se ha usado. Pide uno nuevo a quien te lo envió.",
"oidcOnly": "Este servidor solo permite el inicio de sesión único, así que aquí todavía no se puede asumir un perfil.",
"oidcOnly": "Este servidor solo permite el inicio de sesión único. Continúa con tu proveedor de identidad.",
"signedInTitle": "Cierra la sesión primero",
"signedInBody": "Has iniciado sesión como {username}. Este enlace crea un acceso para otra persona, así que cierra la sesión antes de usarlo.",
"signOut": "Cerrar sesión y continuar",
@@ -545,7 +545,19 @@
"errorTaken": "Ese nombre de usuario o correo electrónico ya está en uso.",
"errorRateLimit": "Demasiados intentos. Espera unos minutos y vuelve a intentarlo.",
"errorFailed": "No ha funcionado. Vuelve a intentarlo en un momento.",
"errorOffline": "No se llegó al servidor. Comprueba tu conexión y vuelve a intentarlo."
"errorOffline": "No se llegó al servidor. Comprueba tu conexión y vuelve a intentarlo.",
"ssoContinue": "Continuar con {provider}",
"ssoStarting": "Redirigiendo…",
"ssoHint": "A partir de entonces inicias sesión aquí con tu cuenta de {provider}.",
"ssoDivider": "o",
"ssoFallbackName": "inicio de sesión único",
"errorTitle": "La entrega no se ha completado",
"errorSubjectBound": "Este inicio de sesión ya pertenece a otra cuenta de este servidor. Vuelve a abrir el enlace y usa otra cuenta en tu proveedor de identidad.",
"errorEmailTaken": "Tu proveedor de identidad ha enviado una dirección de correo que ya usa otra cuenta aquí. Vuelve a abrir el enlace y usa otra cuenta, o pregunta a quien administra el servidor.",
"errorUsernameTaken": "Alguien ha cogido ese nombre de usuario mientras tanto. Vuelve a abrir el enlace y elige otro.",
"errorSignedIn": "Tenías una sesión iniciada al volver. Cierra la sesión y vuelve a abrir el enlace.",
"errorSso": "No se ha podido iniciar sesión con tu proveedor de identidad. Vuelve a abrir el enlace para intentarlo otra vez.",
"signInInstead": "Iniciar sesión en esa cuenta"
},
"inviteInvalid": {
"title": "Invitación no válida",
@@ -11989,9 +12001,11 @@
"revokeFailed": "No se pudo retirar el enlace. Vuelve a intentarlo.",
"errorFailed": "No se pudo crear el enlace de entrega.",
"errorRosterChanged": "Mientras tanto ha cambiado quién se ocupa del historial. Cierra esto y vuelve a abrirlo.",
"oidcOnly": "No se puede entregar un perfil mientras este servidor solo permita el inicio de sesión único.",
"oidcOnly": "En este servidor no se puede entregar un perfil.",
"loadError": "No se pudo cargar el estado de la entrega.",
"entryHint": "Cuando {name} quiera llevar este historial por su cuenta."
"entryHint": "Cuando {name} quiera llevar este historial por su cuenta.",
"methodSso": "{name} inicia sesión con el inicio de sesión único cuando usa el enlace.",
"methodBoth": "{name} puede elegir una contraseña o iniciar sesión con el inicio de sesión único."
}
},
"toast": {
+18 -4
View File
@@ -528,7 +528,7 @@
"loading": "Vérification de votre lien…",
"invalid": "Ce lien ne fonctionne plus",
"invalidHint": "Il est inconnu, expiré, retiré ou déjà utilisé. Demandez-en un nouveau à la personne qui vous l'a envoyé.",
"oidcOnly": "Ce serveur n'autorise que l'authentification unique, un profil ne peut donc pas encore être repris ici.",
"oidcOnly": "Ce serveur n'autorise que l'authentification unique. Continuez plutôt avec votre fournisseur d'identité.",
"signedInTitle": "Déconnectez-vous d'abord",
"signedInBody": "Vous êtes connecté en tant que {username}. Ce lien crée une connexion pour quelqu'un d'autre, déconnectez-vous donc avant de l'utiliser.",
"signOut": "Se déconnecter et continuer",
@@ -545,7 +545,19 @@
"errorTaken": "Ce nom d'utilisateur ou cette adresse e-mail est déjà utilisé.",
"errorRateLimit": "Trop de tentatives. Attendez quelques minutes puis réessayez.",
"errorFailed": "Cela n'a pas fonctionné. Réessayez dans un instant.",
"errorOffline": "Le serveur n'a pas été atteint. Vérifiez votre connexion et réessayez."
"errorOffline": "Le serveur n'a pas été atteint. Vérifiez votre connexion et réessayez.",
"ssoContinue": "Continuer avec {provider}",
"ssoStarting": "Redirection…",
"ssoHint": "Ensuite, vous vous connectez ici avec votre compte {provider}.",
"ssoDivider": "ou",
"ssoFallbackName": "l'authentification unique",
"errorTitle": "La transmission n'a pas abouti",
"errorSubjectBound": "Cette connexion appartient déjà à un autre compte sur ce serveur. Rouvrez le lien et utilisez un autre compte chez votre fournisseur d'identité.",
"errorEmailTaken": "Votre fournisseur d'identité a transmis une adresse e-mail déjà utilisée par un autre compte ici. Rouvrez le lien et utilisez un autre compte, ou contactez la personne qui administre le serveur.",
"errorUsernameTaken": "Quelqu'un a pris ce nom d'utilisateur entre-temps. Rouvrez le lien et choisissez-en un autre.",
"errorSignedIn": "Vous étiez connecté à votre retour. Déconnectez-vous, puis rouvrez le lien.",
"errorSso": "La connexion avec votre fournisseur d'identité n'a pas fonctionné. Rouvrez le lien pour réessayer.",
"signInInstead": "Se connecter à ce compte"
},
"inviteInvalid": {
"title": "Invitation invalide",
@@ -11989,9 +12001,11 @@
"revokeFailed": "Le lien n'a pas pu être retiré. Réessayez.",
"errorFailed": "Le lien de transmission n'a pas pu être créé.",
"errorRosterChanged": "Entre-temps, les personnes qui s'occupent du dossier ont changé. Fermez ceci et rouvrez-le.",
"oidcOnly": "Transmettre un profil n'est pas possible tant que ce serveur n'autorise que l'authentification unique.",
"oidcOnly": "Transmettre un profil n'est pas possible sur ce serveur.",
"loadError": "L'état de la transmission n'a pas pu être chargé.",
"entryHint": "Quand {name} souhaite tenir ce dossier de façon autonome."
"entryHint": "Quand {name} souhaite tenir ce dossier de façon autonome.",
"methodSso": "{name} se connecte par authentification unique en utilisant le lien.",
"methodBoth": "{name} peut définir un mot de passe ou se connecter par authentification unique."
}
},
"toast": {
+18 -4
View File
@@ -528,7 +528,7 @@
"loading": "Controllo del link…",
"invalid": "Questo link non funziona più",
"invalidHint": "È sconosciuto, scaduto, ritirato o già usato. Chiedine uno nuovo a chi te l'ha inviato.",
"oidcOnly": "Questo server consente solo il single sign-on, quindi qui non è ancora possibile prendere in carico un profilo.",
"oidcOnly": "Questo server consente solo il single sign-on. Continua invece con il tuo provider di identità.",
"signedInTitle": "Prima esci",
"signedInBody": "Hai effettuato l'accesso come {username}. Questo link crea un accesso per un'altra persona, quindi esci prima di usarlo.",
"signOut": "Esci e continua",
@@ -545,7 +545,19 @@
"errorTaken": "Questo nome utente o indirizzo email è già in uso.",
"errorRateLimit": "Troppi tentativi. Attendi qualche minuto e riprova.",
"errorFailed": "Non ha funzionato. Riprova tra un momento.",
"errorOffline": "Il server non è stato raggiunto. Controlla la connessione e riprova."
"errorOffline": "Il server non è stato raggiunto. Controlla la connessione e riprova.",
"ssoContinue": "Continua con {provider}",
"ssoStarting": "Reindirizzamento…",
"ssoHint": "Da quel momento accedi qui con il tuo account {provider}.",
"ssoDivider": "oppure",
"ssoFallbackName": "single sign-on (SSO)",
"errorTitle": "La consegna non è andata a buon fine",
"errorSubjectBound": "Questo accesso appartiene già a un altro account su questo server. Riapri il link e usa un altro account presso il tuo provider di identità.",
"errorEmailTaken": "Il tuo provider di identità ha fornito un indirizzo email che qui usa già un altro account. Riapri il link e usa un altro account, oppure chiedi a chi amministra il server.",
"errorUsernameTaken": "Nel frattempo qualcuno ha preso quel nome utente. Riapri il link e scegline un altro.",
"errorSignedIn": "Al ritorno avevi già effettuato l'accesso. Esci, poi riapri il link.",
"errorSso": "L'accesso con il tuo provider di identità non è riuscito. Riapri il link per riprovare.",
"signInInstead": "Accedi a quell'account"
},
"inviteInvalid": {
"title": "Invito non valido",
@@ -11989,9 +12001,11 @@
"revokeFailed": "Impossibile ritirare il link. Riprova.",
"errorFailed": "Impossibile creare il link di consegna.",
"errorRosterChanged": "Nel frattempo è cambiato chi si occupa della cartella. Chiudi e riapri.",
"oidcOnly": "Non è possibile consegnare un profilo finché questo server consente solo il single sign-on.",
"oidcOnly": "Su questo server non è possibile consegnare un profilo.",
"loadError": "Impossibile caricare lo stato della consegna.",
"entryHint": "Quando {name} vuole tenere questa cartella in autonomia."
"entryHint": "Quando {name} vuole tenere questa cartella in autonomia.",
"methodSso": "{name} accede con il single sign-on quando usa il link.",
"methodBoth": "{name} può impostare una password oppure accedere con il single sign-on."
}
},
"toast": {
+18 -4
View File
@@ -528,7 +528,7 @@
"loading": "링크를 확인하는 중…",
"invalid": "이 링크는 더 이상 작동하지 않습니다",
"invalidHint": "알 수 없거나, 만료되었거나, 철회되었거나, 이미 사용된 링크입니다. 보낸 사람에게 새 링크를 요청하세요.",
"oidcOnly": "이 서버는 통합 로그인(SSO)만 허용하므로 아직 여기서 프로필을 넘겨받을 수 없습니다.",
"oidcOnly": "이 서버는 통합 로그인(SSO)만 허용합니다. 대신 ID 공급자로 계속하세요.",
"signedInTitle": "먼저 로그아웃하세요",
"signedInBody": "{username} 계정으로 로그인되어 있습니다. 이 링크는 다른 사람의 로그인을 만드는 링크이므로 사용하기 전에 로그아웃하세요.",
"signOut": "로그아웃하고 계속",
@@ -545,7 +545,19 @@
"errorTaken": "이미 사용 중인 사용자 이름 또는 이메일 주소입니다.",
"errorRateLimit": "시도 횟수가 너무 많습니다. 몇 분 후 다시 시도하세요.",
"errorFailed": "처리하지 못했습니다. 잠시 후 다시 시도하세요.",
"errorOffline": "서버에 연결하지 못했습니다. 연결을 확인하고 다시 시도하세요."
"errorOffline": "서버에 연결하지 못했습니다. 연결을 확인하고 다시 시도하세요.",
"ssoContinue": "{provider}(으)로 계속",
"ssoStarting": "이동하는 중…",
"ssoHint": "이후에는 {provider} 계정으로 여기에 로그인합니다.",
"ssoDivider": "또는",
"ssoFallbackName": "통합 로그인(SSO)",
"errorTitle": "넘겨받기를 완료하지 못했습니다",
"errorSubjectBound": "이 로그인은 이미 이 서버의 다른 계정에 연결되어 있습니다. 링크를 다시 열고 ID 공급자에서 다른 계정을 사용하세요.",
"errorEmailTaken": "ID 공급자가 보낸 이메일 주소를 이 서버의 다른 계정이 이미 사용하고 있습니다. 링크를 다시 열고 다른 계정을 사용하거나 서버 관리자에게 문의하세요.",
"errorUsernameTaken": "그 사이에 다른 사람이 이 사용자 이름을 사용했습니다. 링크를 다시 열고 다른 이름을 선택하세요.",
"errorSignedIn": "돌아왔을 때 이미 로그인되어 있었습니다. 로그아웃한 다음 링크를 다시 여세요.",
"errorSso": "ID 공급자로 로그인하지 못했습니다. 링크를 다시 열어 한 번 더 시도하세요.",
"signInInstead": "그 계정으로 로그인"
},
"inviteInvalid": {
"title": "유효하지 않은 초대예요",
@@ -11989,9 +12001,11 @@
"revokeFailed": "링크를 철회하지 못했습니다. 다시 시도하세요.",
"errorFailed": "넘겨주기 링크를 만들지 못했습니다.",
"errorRosterChanged": "그 사이 기록을 돌보는 사람이 바뀌었습니다. 닫았다가 다시 여세요.",
"oidcOnly": "이 서버가 통합 로그인(SSO)만 허용하는 동안에는 프로필을 넘겨줄 수 없습니다.",
"oidcOnly": "이 서버에서는 프로필을 넘겨줄 수 없습니다.",
"loadError": "넘겨주기 상태를 불러오지 못했습니다.",
"entryHint": "{name} 님이 이 기록을 직접 관리할 준비가 되었을 때."
"entryHint": "{name} 님이 이 기록을 직접 관리할 준비가 되었을 때.",
"methodSso": "{name}님은 링크를 사용할 때 통합 로그인(SSO)으로 로그인합니다.",
"methodBoth": "{name}님은 비밀번호를 정하거나 통합 로그인(SSO)으로 로그인할 수 있습니다."
}
},
"toast": {
+18 -4
View File
@@ -528,7 +528,7 @@
"loading": "Sprawdzanie linku…",
"invalid": "Ten link już nie działa",
"invalidHint": "Jest nieznany, wygasł, został wycofany albo już użyty. Poproś osobę, która go wysłała, o nowy.",
"oidcOnly": "Ten serwer pozwala tylko na logowanie jednokrotne (SSO), więc profilu nie można tu jeszcze przejąć.",
"oidcOnly": "Ten serwer pozwala tylko na logowanie jednokrotne (SSO). Kontynuuj u swojego dostawcy tożsamości.",
"signedInTitle": "Najpierw się wyloguj",
"signedInBody": "Masz zalogowaną sesję jako {username}. Ten link tworzy logowanie dla kogoś innego, więc wyloguj się, zanim go użyjesz.",
"signOut": "Wyloguj i kontynuuj",
@@ -545,7 +545,19 @@
"errorTaken": "Ta nazwa użytkownika lub adres e-mail jest już zajęty.",
"errorRateLimit": "Zbyt wiele prób. Odczekaj kilka minut i spróbuj ponownie.",
"errorFailed": "Nie udało się. Spróbuj ponownie za chwilę.",
"errorOffline": "Nie udało się połączyć z serwerem. Sprawdź połączenie i spróbuj ponownie."
"errorOffline": "Nie udało się połączyć z serwerem. Sprawdź połączenie i spróbuj ponownie.",
"ssoContinue": "Kontynuuj przez {provider}",
"ssoStarting": "Przekierowywanie…",
"ssoHint": "Od tej pory logujesz się tutaj swoim kontem {provider}.",
"ssoDivider": "lub",
"ssoFallbackName": "logowanie jednokrotne (SSO)",
"errorTitle": "Przejęcie się nie udało",
"errorSubjectBound": "To logowanie należy już do innego konta na tym serwerze. Otwórz link ponownie i użyj innego konta u swojego dostawcy tożsamości.",
"errorEmailTaken": "Twój dostawca tożsamości przekazał adres e-mail, którego używa już inne konto na tym serwerze. Otwórz link ponownie i użyj innego konta albo zapytaj osobę, która administruje serwerem.",
"errorUsernameTaken": "W międzyczasie ktoś zajął tę nazwę użytkownika. Otwórz link ponownie i wybierz inną.",
"errorSignedIn": "Po powrocie byłeś zalogowany. Wyloguj się i otwórz link ponownie.",
"errorSso": "Logowanie u dostawcy tożsamości się nie powiodło. Otwórz link ponownie, aby spróbować jeszcze raz.",
"signInInstead": "Zaloguj się na to konto"
},
"inviteInvalid": {
"title": "Nieprawidłowe zaproszenie",
@@ -11989,9 +12001,11 @@
"revokeFailed": "Nie udało się wycofać linku. Spróbuj ponownie.",
"errorFailed": "Nie udało się utworzyć linku przekazania.",
"errorRosterChanged": "W międzyczasie zmieniło się, kto opiekuje się kartą. Zamknij to i otwórz ponownie.",
"oidcOnly": "Przekazanie profilu nie jest możliwe, dopóki ten serwer pozwala tylko na logowanie jednokrotne (SSO).",
"oidcOnly": "Przekazanie profilu nie jest możliwe na tym serwerze.",
"loadError": "Nie udało się wczytać stanu przekazania.",
"entryHint": "Gdy {name} chce samodzielnie prowadzić tę kartę."
"entryHint": "Gdy {name} chce samodzielnie prowadzić tę kartę.",
"methodSso": "{name} loguje się przez logowanie jednokrotne (SSO), gdy użyje linku.",
"methodBoth": "{name} może ustawić hasło albo zalogować się przez logowanie jednokrotne (SSO)."
}
},
"toast": {
@@ -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"],
"dawarich/connect/route.ts": ["dawarich.connect"],
"dawarich/disconnect/route.ts": ["dawarich.disconnect"],
"dawarich/places/route.ts": ["dawarich.places.get"],
@@ -61,7 +60,7 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
};
/** The size of the register when it was written; see the header. */
const REGISTER_SIZE = 21;
const REGISTER_SIZE = 20;
function routeFiles(): string[] {
return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] })
+46 -2
View File
@@ -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
+174 -12
View File
@@ -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" } }),
);
});
});
+12 -1
View File
@@ -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(),
});
});
+1 -1
View File
@@ -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,
+69 -14
View File
@@ -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;
});
+95 -2
View File
@@ -38,6 +38,18 @@ 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";
import { isHandoverUsable } from "@/lib/managed-profiles/handover";
const LOGIN_ERROR_URL = "/auth/login";
@@ -102,6 +114,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 +142,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 +180,27 @@ 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).
// v1.43 (#959) — 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.
// Browser-only, like `errorRedirect`: a claim is never native.
const claimRedirect = (reason: string): NextResponse => {
const response = NextResponse.redirect(
oidcAppUrl(`${CLAIM_ERROR_PATH}?error=${reason}`),
);
deleteStateCookie(response);
clearClaimCookie(response);
return response;
};
const failRedirect = (reason: string): NextResponse => {
if (isClaim) return claimRedirect(reason);
if (isNative) {
const response = NextResponse.redirect(
buildNativeCallbackUrl({ error: reason }),
@@ -198,7 +240,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 ? claimRedirect("oidc_state") : errorRedirect("oidc_state");
}
if (!stored) {
// Defence-in-depth idempotency for a duplicated callback. The single-use
@@ -240,7 +284,34 @@ 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 ? claimRedirect("oidc_state") : errorRedirect("oidc_state");
}
// v1.43 (#959) — a claim is never finished over a live session (the claim
// routes' rule: a Guardian finishing their own link would otherwise be
// signed out of their account and into the person's), and is admitted only
// with its own sealed cookie, still bound to this sign-in, for a link that
// is still usable. All of it before the code is redeemed, so a refused claim
// spends nothing at the provider.
const pendingClaim = isClaim
? readClaimCookie(req.cookies.get(CLAIM_COOKIE)?.value)
: null;
if (isClaim) {
const liveSession = await validateSessionFromCookieValue(
req.cookies.get(SESSION_COOKIE_NAME)?.value,
);
if (liveSession) {
annotate({ meta: { reason: "claim_signed_in" } });
return failRedirect("claim_signed_in");
}
if (
!pendingClaim ||
!claimMatchesBinding(pendingClaim, stored.claimBinding as string) ||
!(await isHandoverUsable(pendingClaim.handoverId))
) {
annotate({ meta: { reason: "claim_expired" } });
return failRedirect("claim_expired");
}
}
try {
@@ -274,6 +345,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",
);
});
});
+50 -5
View File
@@ -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, {
+9 -2
View File
@@ -6,7 +6,9 @@ import { ClaimProfile } from "@/components/auth/claim-profile";
/**
* v1.42 (#959) — `/auth/claim?token=hlp_…`, where the link a Guardian handed
* over (`/claim/<token>`) lands after the edge redirect.
* over (`/claim/<token>`) lands after the edge redirect. Since v1.43 also
* `/auth/claim?error=<reason>`, where a refused single-sign-on claim returns;
* the reason is a fixed word, never a credential.
*
* A URL-credential surface by shape (`session-surface-guard.test.ts`, S2): the
* page reads the one-time token off the query string and passes it to the
@@ -16,5 +18,10 @@ import { ClaimProfile } from "@/components/auth/claim-profile";
*/
export default function ClaimProfilePage() {
const searchParams = useSearchParams();
return <ClaimProfile token={searchParams.get("token")} />;
return (
<ClaimProfile
token={searchParams.get("token")}
error={searchParams.get("error")}
/>
);
}
@@ -19,6 +19,7 @@ vi.mock("@/hooks/use-auth", () => ({
vi.mock("@/lib/queries/use-profile-claim", () => ({
useProfileClaimPreview: () => ({ data: undefined, error: null }),
useClaimProfile: () => ({ mutate: () => {}, isPending: false }),
useClaimProfileSso: () => ({ mutate: () => {}, isPending: false }),
}));
import { ClaimProfile } from "../claim-profile";
@@ -0,0 +1,105 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { ProfileClaimPreview } from "@/lib/queries/use-profile-claim";
/**
* v1.43 (#959) — the claim page offers the ways in the server names: the
* password form, the identity provider, or both. A refused single-sign-on
* claim comes back with `?error=` and no link, and says what happened.
*/
let previewData: ProfileClaimPreview | undefined;
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: () => {}, refresh: () => {}, push: () => {} }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: null }),
clearCachesForSessionEnd: () => {},
}));
vi.mock("@/lib/queries/use-profile-claim", () => ({
useProfileClaimPreview: () => ({ data: previewData, error: null }),
useClaimProfile: () => ({ mutate: () => {}, isPending: false }),
useClaimProfileSso: () => ({ mutate: () => {}, isPending: false }),
}));
import { ClaimProfile, claimSsoErrorKey } from "../claim-profile";
const BASE: ProfileClaimPreview = {
displayName: "Robin",
expiresAt: "2030-01-01T00:00:00.000Z",
guardians: [],
};
function render(props: { token: string | null; error?: string | null }) {
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">
<ClaimProfile {...props} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("ClaimProfile — ways in", () => {
it("offers only the provider on a single-sign-on-only server", () => {
previewData = { ...BASE, methods: ["sso"], ssoProviderName: "Example ID" };
const html = render({ token: "hlp_x" });
expect(html).toContain('data-slot="claim-sso-continue"');
expect(html).toContain("Continue with Example ID");
expect(html).not.toContain('id="claim-password"');
expect(html).not.toContain('id="claim-email"');
expect(html).not.toContain('data-slot="claim-submit"');
expect(html).toContain('id="claim-username"');
});
it("offers both when both are allowed", () => {
previewData = {
...BASE,
methods: ["password", "sso"],
ssoProviderName: "Example ID",
};
const html = render({ token: "hlp_x" });
expect(html).toContain('data-slot="claim-sso-continue"');
expect(html).toContain('id="claim-password"');
expect(html).toContain('data-slot="claim-submit"');
});
it("offers only the password when an older server names no methods", () => {
previewData = { ...BASE };
const html = render({ token: "hlp_x" });
expect(html).not.toContain('data-slot="claim-sso-continue"');
expect(html).toContain('id="claim-password"');
});
});
describe("ClaimProfile — a refused single-sign-on claim", () => {
it("says what happened and offers a way on", () => {
previewData = undefined;
const html = render({ token: null, error: "claim_sso_subject_bound" });
expect(html).toContain('data-testid="claim-sso-error"');
expect(html).toContain("already belongs to another account");
expect(html).toContain('href="/auth/login"');
});
it("maps every reason the callback sends, and reads an unknown one as a failure", () => {
expect(claimSsoErrorKey("claim_expired")).toBe("auth.claim.invalidHint");
expect(claimSsoErrorKey("claim_sso_subject_bound")).toBe(
"auth.claim.errorSubjectBound",
);
expect(claimSsoErrorKey("claim_email_taken")).toBe(
"auth.claim.errorEmailTaken",
);
expect(claimSsoErrorKey("claim_username_taken")).toBe(
"auth.claim.errorUsernameTaken",
);
expect(claimSsoErrorKey("claim_signed_in")).toBe(
"auth.claim.errorSignedIn",
);
expect(claimSsoErrorKey("oidc_failed")).toBe("auth.claim.errorSso");
expect(claimSsoErrorKey("<script>")).toBe("auth.claim.errorSso");
});
});
+209 -54
View File
@@ -4,9 +4,10 @@ import { useId, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { KeyRound, Loader2, LogOut } from "lucide-react";
import { KeyRound, Loader2, LogIn, LogOut } from "lucide-react";
import { accessLabel } from "@/components/settings/access/managed-profile-handover";
import { useClientTransport } from "@/components/auth/insecure-transport-banner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -15,8 +16,10 @@ import { PasswordStrength } from "@/components/ui/password-strength";
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
import { ApiError, apiFetchRaw } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { loadDocument } from "@/lib/navigation/load-document";
import {
useClaimProfile,
useClaimProfileSso,
useProfileClaimPreview,
} from "@/lib/queries/use-profile-claim";
import { queryKeys } from "@/lib/query-keys";
@@ -33,8 +36,22 @@ import { queryKeys } from "@/lib/query-keys";
* record's name, who keeps which access for now, and until when the link
* holds. No health data. The decision about each Guardian comes right after
* the first sign-in, on the screen the claim lands on.
*
* v1.43 — the ways in follow the preview's `methods`: the password form, a
* "Continue with <provider>" button that claims through single sign-on, or
* both. The single-sign-on leg leaves the page for the identity provider and
* comes back to `/onboarding/handover`, or to this page with `?error=` when
* the callback refused; the link is not in that URL, so the error card asks
* the person to open the link again.
*/
export function ClaimProfile({ token }: { token: string | null }) {
export function ClaimProfile({
token,
error = null,
}: {
token: string | null;
/** The single-sign-on callback's refusal, from `?error=`. */
error?: string | null;
}) {
const router = useRouter();
const queryClient = useQueryClient();
const { t } = useTranslations();
@@ -44,6 +61,8 @@ export function ClaimProfile({ token }: { token: string | null }) {
const { user } = useAuth();
const preview = useProfileClaimPreview(token, !user);
const claim = useClaimProfile();
const claimSso = useClaimProfileSso();
const clientTransport = useClientTransport();
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
@@ -73,6 +92,25 @@ export function ClaimProfile({ token }: { token: string | null }) {
}
}
function continueWithSso() {
if (!token || claimSso.isPending || usernameReserved) return;
claimSso.mutate(
{ token, username },
{
onSuccess: ({ redirectTo }) => {
// Only the path and query of the server's answer, so a response
// could never send the browser off this origin.
const next = new URL(redirectTo, window.location.origin);
if (clientTransport) {
next.searchParams.set("client_proto", clientTransport.protocol);
next.searchParams.set("client_host", clientTransport.host);
}
loadDocument(`${next.pathname}${next.search}`);
},
},
);
}
function submit(event: React.FormEvent) {
event.preventDefault();
if (!token || claim.isPending || usernameReserved) return;
@@ -118,6 +156,35 @@ export function ClaimProfile({ token }: { token: string | null }) {
);
}
if (!token && error) {
const bound = error === "claim_sso_subject_bound";
return (
<ClaimCard testId="claim-sso-error">
<Header
title={
error === "claim_expired"
? t("auth.claim.invalid")
: t("auth.claim.errorTitle")
}
description={t(claimSsoErrorKey(error))}
/>
{/* The way on: the sign-in page (for a sign-in that already belongs
to an account, that account's). The link itself is not here. */}
<Button
asChild
variant="outline"
size="lg"
className="mt-8 min-h-11 w-full"
data-slot="claim-to-login"
>
<Link href="/auth/login">
{bound ? t("auth.claim.signInInstead") : t("auth.login")}
</Link>
</Button>
</ClaimCard>
);
}
if (!token || previewStatus === 404 || previewStatus === 422) {
return (
<ClaimCard testId="claim-invalid">
@@ -192,12 +259,18 @@ export function ClaimProfile({ token }: { token: string | null }) {
}
const data = preview.data;
const errorKey = claim.isError ? claimErrorKey(claim.error) : null;
// An older server sends no `methods`: it only ever offered the password.
const methods = data.methods ?? ["password"];
const offersPassword = methods.includes("password");
const offersSso = methods.includes("sso");
const providerName = data.ssoProviderName ?? t("auth.claim.ssoFallbackName");
const failed = claimSso.isError ? claimSso : claim;
const errorKey = failed.isError ? claimErrorKey(failed.error) : null;
const errorText =
claim.error instanceof ApiError &&
claim.error.status === 422 &&
claim.error.message
? claim.error.message
failed.error instanceof ApiError &&
failed.error.status === 422 &&
failed.error.message
? failed.error.message
: errorKey
? t(errorKey)
: null;
@@ -241,7 +314,19 @@ export function ClaimProfile({ token }: { token: string | null }) {
</p>
</section>
<form onSubmit={submit} className="mt-6 space-y-4" data-slot="claim-form">
<form
onSubmit={
offersPassword
? submit
: (event) => {
// Enter in the username field on an SSO-only server.
event.preventDefault();
continueWithSso();
}
}
className="mt-6 space-y-4"
data-slot="claim-form"
>
<div className="space-y-2">
<Label htmlFor="claim-username">{t("auth.username")}</Label>
<Input
@@ -270,36 +355,43 @@ export function ClaimProfile({ token }: { token: string | null }) {
: t("auth.claim.usernameHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="claim-email">{t("auth.email")}</Label>
<Input
id="claim-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
autoCapitalize="none"
spellCheck={false}
aria-describedby={`${errorId}-email`}
/>
<p id={`${errorId}-email`} className="text-muted-foreground text-xs">
{t("auth.claim.emailHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="claim-password">{t("auth.password")}</Label>
<Input
id="claim-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="new-password"
placeholder={t("auth.passwordMinLength")}
/>
<PasswordStrength password={password} />
</div>
{offersPassword && (
<>
<div className="space-y-2">
<Label htmlFor="claim-email">{t("auth.email")}</Label>
<Input
id="claim-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
autoCapitalize="none"
spellCheck={false}
aria-describedby={`${errorId}-email`}
/>
<p
id={`${errorId}-email`}
className="text-muted-foreground text-xs"
>
{t("auth.claim.emailHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="claim-password">{t("auth.password")}</Label>
<Input
id="claim-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="new-password"
placeholder={t("auth.passwordMinLength")}
/>
<PasswordStrength password={password} />
</div>
</>
)}
<p className="text-sm">{t("auth.claim.consentNote")}</p>
<p className="text-muted-foreground text-xs">
@@ -320,22 +412,63 @@ export function ClaimProfile({ token }: { token: string | null }) {
</div>
)}
<Button
type="submit"
size="lg"
className="min-h-11 w-full"
disabled={claim.isPending || usernameReserved}
data-slot="claim-submit"
>
{claim.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="size-4" />
)}
{claim.isPending
? t("auth.claim.submitting")
: t("auth.claim.submit")}
</Button>
{offersPassword && (
<Button
type="submit"
size="lg"
className="min-h-11 w-full"
disabled={claim.isPending || usernameReserved}
data-slot="claim-submit"
>
{claim.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="size-4" />
)}
{claim.isPending
? t("auth.claim.submitting")
: t("auth.claim.submit")}
</Button>
)}
{offersPassword && offersSso && (
<div
className="text-muted-foreground flex items-center gap-3 text-xs"
aria-hidden="true"
>
<span className="bg-border h-px flex-1" />
{t("auth.claim.ssoDivider")}
<span className="bg-border h-px flex-1" />
</div>
)}
{offersSso && (
<div className="space-y-2" data-slot="claim-sso">
<Button
type="button"
size="lg"
variant={offersPassword ? "outline" : "default"}
className="min-h-11 w-full"
disabled={
claimSso.isPending || usernameReserved || username.length < 3
}
onClick={continueWithSso}
data-slot="claim-sso-continue"
>
{claimSso.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<LogIn className="size-4" />
)}
{claimSso.isPending
? t("auth.claim.ssoStarting")
: t("auth.claim.ssoContinue", { provider: providerName })}
</Button>
<p className="text-muted-foreground text-xs">
{t("auth.claim.ssoHint", { provider: providerName })}
</p>
</div>
)}
</form>
</ClaimCard>
);
@@ -378,6 +511,28 @@ function Header({
);
}
/**
* v1.43 — the message for the single-sign-on callback's refusal, from the
* claim page's `?error=`. Exported and pure so it can be pinned. A reason this
* build does not know reads as the generic failure.
*/
export function claimSsoErrorKey(reason: string): string {
switch (reason) {
case "claim_expired":
return "auth.claim.invalidHint";
case "claim_sso_subject_bound":
return "auth.claim.errorSubjectBound";
case "claim_email_taken":
return "auth.claim.errorEmailTaken";
case "claim_username_taken":
return "auth.claim.errorUsernameTaken";
case "claim_signed_in":
return "auth.claim.errorSignedIn";
default:
return "auth.claim.errorSso";
}
}
/** The message for a refused claim. Exported and pure so it can be pinned. */
export function claimErrorKey(err: unknown): string {
if (!(err instanceof ApiError)) return "auth.claim.errorOffline";
@@ -110,6 +110,18 @@ export function ManagedProfileHandover({
name: profileName,
})}
</p>
{/* v1.43 — how the person will get in, when it is not only a password
they set themselves (the description already implies that one). */}
{status.data.available && status.data.methods?.includes("sso") ? (
<p className="text-sm" data-slot="managed-profile-handover-methods">
{t(
status.data.methods.includes("password")
? "recordSharing.managed.handover.methodBoth"
: "recordSharing.managed.handover.methodSso",
{ name: profileName },
)}
</p>
) : null}
{!status.data.available ? (
<>
+20
View File
@@ -1,3 +1,4 @@
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import { describe, expect, it } from "vitest";
import { redactSecrets, redactOptional, PATH_SECRET_PATHS } from "../redact";
@@ -249,6 +250,25 @@ describe("redactSecrets", () => {
"/api/auth/claim/preview",
);
expect(redactSecrets("/api/auth/claim")).toBe("/api/auth/claim");
// v1.43 (#959) — the single-sign-on start and its landing carry no
// credential in the URL, and stay readable.
expect(redactSecrets("/api/auth/claim/sso")).toBe("/api/auth/claim/sso");
expect(redactSecrets("/api/auth/oidc/login?intent=claim")).toBe(
"/api/auth/oidc/login?intent=claim",
);
expect(redactSecrets("/auth/claim?error=claim_expired")).toBe(
"/auth/claim?error=claim_expired",
);
});
it("redacts the handover token in a single-sign-on claim body", () => {
const token = `hlp_${"ef".repeat(32)}`;
const excerpt = JSON.stringify(
redactSensitiveFields({ token, username: "robin" }),
);
expect(excerpt).not.toContain(token);
expect(excerpt).toContain("robin");
expect(redactSecrets(JSON.stringify({ token }))).not.toContain(token);
});
it("registers the clinician share-view prefix in PATH_SECRET_PATHS", () => {
+111
View File
@@ -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 });
}
+42 -9
View File
@@ -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({
+178
View File
@@ -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 };
}
+92 -15
View File
@@ -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,
});
+1
View File
@@ -355,6 +355,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"],
+1 -1
View File
@@ -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": {
+9 -10
View File
@@ -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.",
},
},
},
+7 -1
View File
@@ -20,6 +20,7 @@
* invalidation path (`grantDependentKeys`).
*/
import type { HandoverMethod } from "@/lib/validations/claim-sso";
import {
useMutation,
useQuery,
@@ -346,8 +347,13 @@ export function useRemoveManagedProfileGuardian() {
/** What `GET /api/managed-profiles/{id}/handover` answers. */
export interface ManagedProfileHandoverStatus {
/** False on a single-sign-on-only instance, where no link can be made. */
/** False only when no way to redeem a link exists on this instance. */
available: boolean;
/**
* v1.43 — how the person can redeem the link here. Absent from an older
* server, which only offered the password.
*/
methods?: HandoverMethod[];
open: {
id: string;
createdAt: string;
+36
View File
@@ -12,6 +12,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { apiPost } from "@/lib/api/api-fetch";
import type { HandoverAccess } from "@/lib/managed-profiles/handover-access";
import type { HandoverMethod } from "@/lib/validations/claim-sso";
import { queryKeys } from "@/lib/query-keys";
export interface ProfileClaimPreview {
@@ -21,6 +22,13 @@ export interface ProfileClaimPreview {
displayName: string | null;
proposal: HandoverAccess;
}[];
/**
* v1.43 — the ways in this server offers. Absent from an older server,
* which only ever offered the password.
*/
methods?: HandoverMethod[];
/** v1.43 — the provider's name for the button, or null. */
ssoProviderName?: string | null;
}
export function useProfileClaimPreview(token: string | null, enabled: boolean) {
@@ -64,3 +72,31 @@ export function useClaimProfile() {
),
});
}
/** Exactly the body `POST /api/auth/claim/sso` accepts (strict). */
export interface ProfileClaimSsoInput {
token: string;
username: string;
}
export function claimSsoBody(
input: ProfileClaimSsoInput,
): ProfileClaimSsoInput {
return { token: input.token, username: input.username };
}
/**
* v1.43 (#959) — start claiming through the identity provider. The answer
* sets the sealed claim cookie and names where the browser goes next; the
* caller navigates there as a full page load.
*/
export function useClaimProfileSso() {
return useMutation({
mutationKey: queryKeys.profileClaimSso(),
mutationFn: (input: ProfileClaimSsoInput) =>
apiPost<{ redirectTo: string }>(
"/api/auth/claim/sso",
claimSsoBody(input),
),
});
}
+2
View File
@@ -99,4 +99,6 @@ export const sharingKeys = {
["managed-profiles", "handover", "revoke"] as const,
handoverDecide: () => ["account", "handover-decision", "decide"] as const,
profileClaim: () => ["profile-claim", "submit"] as const,
/** v1.43 (#959) — starting the claim through single sign-on. */
profileClaimSso: () => ["profile-claim", "sso"] as const,
};
+1
View File
@@ -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];
@@ -0,0 +1,906 @@
/**
* v1.43 (#959) — handing a managed profile over on a server that signs in
* through an identity provider, end to end: the real start route, the real
* OIDC login and callback, a real Postgres, and a mock identity provider that
* speaks the wire (discovery, JWKS, a token endpoint that checks PKCE, and
* ES256-signed ID tokens), so state, nonce, PKCE and the signature are the
* production code's own checks rather than stubs.
*
* Every refusal is asserted twice: where the browser lands, and that nothing
* about any account changed (the profile is still managed, the subject is not
* bound, nobody got a session, the link is still as it was).
*/
import { createServer, type Server } from "node:http";
import { createHash } from "node:crypto";
import type { AddressInfo } from "node:net";
import { exportJWK, generateKeyPair, SignJWT, type CryptoKey } from "jose";
import { NextRequest } from "next/server";
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { createManagedProfile } from "@/lib/managed-profiles/create";
import { acceptGrant, inviteGrant } from "@/lib/sharing/grants";
import { readPendingHandoverDecision } from "@/lib/managed-profiles/handover";
import { _resetOidcCacheForTests } from "@/lib/auth/oidc";
import { handoverCreateBody } from "@/lib/queries/use-managed-profiles";
import { claimBody, claimSsoBody } from "@/lib/queries/use-profile-claim";
import type { HandoverAccess } from "@/lib/managed-profiles/handover-access";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => cookieJar.set(name, value),
delete: (name: string) => cookieJar.delete(name),
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/password-breach-check", () => ({
checkPasswordBreachIfEnabled: vi.fn().mockResolvedValue(null),
}));
const sent: { userId: string; messageKey: string }[] = [];
vi.mock("@/lib/notifications/dispatch-localised", () => ({
dispatchLocalisedNotification: vi.fn(
async (opts: { userId: string; messageKey: string }) => {
sent.push({ userId: opts.userId, messageKey: opts.messageKey });
},
),
}));
const APP = "http://localhost:3000";
const CLIENT_ID = "healthlog-test";
const CLIENT_SECRET = "client-secret-for-the-mock-idp";
// ── The mock identity provider ──────────────────────────────────────────────
interface Grant {
sub: string;
email?: string;
emailVerified?: boolean;
nonce: string;
codeChallenge: string;
/** The provider would redeem the code more than once (a replay test). */
reusable?: boolean;
/** Sign the ID token with this nonce instead of the requested one. */
nonceOverride?: string;
}
let idp: Server;
let ISSUER = "";
let privateKey: CryptoKey;
let publicJwk: Record<string, unknown>;
const grants = new Map<string, Grant>();
const redeemed: string[] = [];
function s256(verifier: string): string {
return createHash("sha256").update(verifier).digest("base64url");
}
async function readBody(req: import("node:http").IncomingMessage) {
let body = "";
for await (const chunk of req) body += chunk;
return new URLSearchParams(body);
}
beforeAll(async () => {
const pair = await generateKeyPair("ES256");
privateKey = pair.privateKey;
publicJwk = { ...(await exportJWK(pair.publicKey)), kid: "k1", alg: "ES256" };
idp = createServer(async (req, res) => {
const json = (status: number, body: unknown) => {
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(body));
};
const path = new URL(req.url ?? "/", ISSUER).pathname;
if (path === "/.well-known/openid-configuration") {
return json(200, {
issuer: ISSUER,
authorization_endpoint: `${ISSUER}/authorize`,
token_endpoint: `${ISSUER}/token`,
jwks_uri: `${ISSUER}/jwks`,
});
}
if (path === "/jwks") return json(200, { keys: [publicJwk] });
if (path === "/token" && req.method === "POST") {
const form = await readBody(req);
const code = form.get("code") ?? "";
const grant = grants.get(code);
if (
!grant ||
form.get("client_id") !== CLIENT_ID ||
form.get("client_secret") !== CLIENT_SECRET ||
form.get("redirect_uri") !== `${APP}/api/auth/oidc/callback` ||
// PKCE, as a provider enforces it.
s256(form.get("code_verifier") ?? "") !== grant.codeChallenge
) {
return json(400, { error: "invalid_grant" });
}
if (!grant.reusable) grants.delete(code);
redeemed.push(code);
const idToken = await new SignJWT({
nonce: grant.nonceOverride ?? grant.nonce,
...(grant.email !== undefined ? { email: grant.email } : {}),
...(grant.emailVerified !== undefined
? { email_verified: grant.emailVerified }
: {}),
})
.setProtectedHeader({ alg: "ES256", kid: "k1" })
.setIssuer(ISSUER)
.setAudience(CLIENT_ID)
.setSubject(grant.sub)
.setIssuedAt()
.setExpirationTime("5m")
.sign(privateKey);
return json(200, { id_token: idToken, token_type: "Bearer" });
}
json(404, { error: "not_found" });
});
await new Promise<void>((resolve) => idp.listen(0, "127.0.0.1", resolve));
ISSUER = `http://127.0.0.1:${(idp.address() as AddressInfo).port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => idp.close(() => resolve()));
});
// ── The household ───────────────────────────────────────────────────────────
let sequence = 0;
async function person(label: string) {
const suffix = sequence++;
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: `${label}-${suffix}`,
email: `${label}-${suffix}@example.test`,
totpConfirmedAt: new Date(),
},
});
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
mfaVerifiedAt: new Date(),
},
});
return { id: user.id, username: user.username, sessionId: session.id };
}
/** A profile with two Guardians: its creator and one more. */
async function household() {
const creator = await person("creator");
const second = await person("second");
const { profile, creatorGrant } = await createManagedProfile({
creatorId: creator.id,
displayName: "Robin",
dateOfBirth: null,
locale: "en",
timezone: "UTC",
});
const invitation = await inviteGrant({
grantorId: profile.id,
granteeId: second.id,
access: "MANAGE",
scope: null,
});
const secondGrant = await acceptGrant({
grantId: invitation.id,
granteeId: second.id,
});
return { creator, second, profile, creatorGrant, secondGrant };
}
const json = (body: unknown) => ({
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
/** Mint a link as the creator (the Guardian side), then sign out. */
async function mintToken(
creator: { sessionId: string },
profileId: string,
proposals: { grantId: string; proposal: HandoverAccess }[] = [],
): Promise<string> {
cookieJar.set("healthlog_session", creator.sessionId);
const { POST } =
await import("@/app/api/managed-profiles/[id]/handover/route");
const response = await POST(
new NextRequest(
`${APP}/api/managed-profiles/${profileId}/handover`,
json(handoverCreateBody({ profileId, expiresInDays: 7, proposals })),
),
{ params: Promise.resolve({ id: profileId }) },
);
expect(response.status).toBe(201);
cookieJar.clear();
return (await response.json()).data.token as string;
}
async function handoverStatus(creator: { sessionId: string }, id: string) {
cookieJar.set("healthlog_session", creator.sessionId);
const { GET } =
await import("@/app/api/managed-profiles/[id]/handover/route");
const response = await GET(
new NextRequest(`${APP}/api/managed-profiles/${id}/handover`),
{ params: Promise.resolve({ id }) },
);
cookieJar.clear();
return response;
}
async function preview(token: string) {
const { POST } = await import("@/app/api/auth/claim/preview/route");
return POST(
new NextRequest(`${APP}/api/auth/claim/preview`, json({ token })),
);
}
async function passwordClaim(token: string) {
const suffix = sequence++;
const { POST } = await import("@/app/api/auth/claim/route");
return POST(
new NextRequest(
`${APP}/api/auth/claim`,
json(
claimBody({
token,
username: `robinpw${suffix}`,
email: `robin-pw-${suffix}@example.test`,
password: "correct horse battery staple 1959",
}),
),
),
);
}
async function startSso(token: string, username: string) {
const { POST } = await import("@/app/api/auth/claim/sso/route");
return POST(
new NextRequest(
`${APP}/api/auth/claim/sso`,
json(claimSsoBody({ token, username })),
),
);
}
function cookieHeader(cookies: Record<string, string | undefined>): string {
return Object.entries(cookies)
.filter((entry): entry is [string, string] => Boolean(entry[1]))
.map(([k, v]) => `${k}=${v}`)
.join("; ");
}
/** The value a response sets for a cookie, or undefined. */
function setCookie(response: Response, name: string): string | undefined {
const cookie = (response as NextResponseLike).cookies?.get(name);
return cookie?.value || undefined;
}
type NextResponseLike = Response & {
cookies?: { get(name: string): { value: string } | undefined };
};
async function login(claimCookie: string | undefined, query = "intent=claim") {
const { GET } = await import("@/app/api/auth/oidc/login/route");
return GET(
new NextRequest(`${APP}/api/auth/oidc/login?${query}`, {
headers: { cookie: cookieHeader({ hl_claim: claimCookie }) },
}),
);
}
interface Identity {
sub: string;
email?: string;
emailVerified?: boolean;
reusable?: boolean;
nonceOverride?: string;
}
/** Where the provider sends the browser back, and with which cookies. */
interface Callback {
url: string;
stateCookie: string;
claimCookie: string | undefined;
}
/** The provider authenticates `identity` for an authorize redirect. */
function authorize(loginResponse: Response, identity: Identity, code: string) {
const location = new URL(loginResponse.headers.get("location") ?? "");
expect(location.origin + location.pathname).toBe(`${ISSUER}/authorize`);
expect(location.searchParams.get("code_challenge_method")).toBe("S256");
grants.set(code, {
...identity,
nonce: location.searchParams.get("nonce") ?? "",
codeChallenge: location.searchParams.get("code_challenge") ?? "",
});
return location.searchParams.get("state") ?? "";
}
async function callback(
cb: Callback,
extra: Record<string, string | undefined> = {},
) {
const { GET } = await import("@/app/api/auth/oidc/callback/route");
return GET(
new NextRequest(cb.url, {
headers: {
cookie: cookieHeader({
oidc_auth_state: cb.stateCookie,
hl_claim: cb.claimCookie,
...extra,
}),
},
}),
);
}
/** Start, log in and come back from the provider; the callback not yet run. */
async function throughProvider(
token: string,
identity: Identity,
username = `robin${sequence++}`,
): Promise<Callback> {
const started = await startSso(token, username);
expect(started.status).toBe(200);
expect((await started.json()).data.redirectTo).toBe(
"/api/auth/oidc/login?intent=claim",
);
const claimCookie = setCookie(started, "hl_claim");
expect(claimCookie).toBeDefined();
const loginResponse = await login(claimCookie);
const stateCookie = setCookie(loginResponse, "oidc_auth_state");
expect(stateCookie).toBeDefined();
const code = `code-${sequence++}`;
const state = authorize(loginResponse, identity, code);
return {
url: `${APP}/api/auth/oidc/callback?code=${code}&state=${encodeURIComponent(state)}`,
stateCookie: stateCookie!,
claimCookie,
};
}
function landedOn(response: Response): string {
expect([302, 303, 307]).toContain(response.status);
const location = new URL(response.headers.get("location") ?? "");
expect(location.origin).toBe(APP);
return location.pathname + location.search;
}
async function profileRow(id: string) {
return getPrismaClient().user.findUniqueOrThrow({
where: { id },
select: {
username: true,
email: true,
passwordHash: true,
oidcIssuer: true,
oidcSub: true,
managedProfileAt: true,
onboardingCompletedAt: true,
},
});
}
/** The account did not change at all, and nobody signed in to it. */
async function expectUntouched(profileId: string) {
const row = await profileRow(profileId);
expect(row.managedProfileAt).not.toBeNull();
expect(row.oidcSub).toBeNull();
expect(row.email).toBeNull();
expect(row.username.startsWith("managed-")).toBe(true);
expect(
await getPrismaClient().session.count({ where: { userId: profileId } }),
).toBe(0);
expect(
await getPrismaClient().auditLog.count({
where: { userId: profileId, action: "auth.claim" },
}),
).toBe(0);
}
function oidcEnv(only: boolean) {
process.env.OIDC_ISSUER_URL = ISSUER;
process.env.OIDC_CLIENT_ID = CLIENT_ID;
process.env.OIDC_CLIENT_SECRET = CLIENT_SECRET;
process.env.OIDC_BUTTON_LABEL = "Example ID";
if (only) process.env.OIDC_ONLY = "true";
else delete process.env.OIDC_ONLY;
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
grants.clear();
redeemed.length = 0;
sent.length = 0;
_resetOidcCacheForTests();
process.env.APP_URL = APP;
process.env.NEXT_PUBLIC_APP_URL = APP;
oidcEnv(true);
});
afterEach(() => {
for (const key of [
"OIDC_ISSUER_URL",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_BUTTON_LABEL",
"OIDC_ONLY",
]) {
delete process.env[key];
}
});
// ── The tests ───────────────────────────────────────────────────────────────
describe("claiming through single sign-on on an SSO-only server", () => {
it("hands the profile over and binds the provider's subject", async () => {
const { creator, second, profile, secondGrant } = await household();
// The Guardian side: available, through single sign-on only.
const status = (await (await handoverStatus(creator, profile.id)).json())
.data;
expect(status.available).toBe(true);
expect(status.methods).toEqual(["sso"]);
const token = await mintToken(creator, profile.id, [
{ grantId: secondGrant.id, proposal: "read" },
]);
// The person's side: the preview names the provider, the password claim
// stays closed.
const shown = (await (await preview(token)).json()).data;
expect(shown.methods).toEqual(["sso"]);
expect(shown.ssoProviderName).toBe("Example ID");
const refused = await passwordClaim(token);
expect(refused.status).toBe(403);
expect((await refused.json()).meta.errorCode).toBe(
"profile_claim.oidc_only_unsupported",
);
const cb = await throughProvider(
token,
{
sub: "subject-robin",
email: "Robin@Example.test",
emailVerified: true,
},
"robin",
);
const response = await callback(cb);
expect(landedOn(response)).toBe("/onboarding/handover");
// Both single-use cookies go with the answer.
expect(response.headers.get("set-cookie")).toMatch(/hl_claim=;/);
expect(response.headers.get("set-cookie")).toMatch(/oidc_auth_state=;/);
// The provider redeemed the code with the matching PKCE verifier.
expect(redeemed).toHaveLength(1);
const row = await profileRow(profile.id);
expect(row).toMatchObject({
username: "robin",
email: "robin@example.test",
passwordHash: null,
oidcIssuer: ISSUER,
oidcSub: "subject-robin",
managedProfileAt: null,
onboardingCompletedAt: null,
});
// The link is spent; a session exists for the new owner, and only one.
const link =
await getPrismaClient().managedProfileHandover.findFirstOrThrow({
where: { profileId: profile.id },
});
expect(link.usedAt).not.toBeNull();
expect(
await getPrismaClient().session.count({ where: { userId: profile.id } }),
).toBe(1);
expect(cookieJar.get("healthlog_session")).toBeDefined();
// The Guardians' proposals hold and wait for the owner's decision.
const decision = await readPendingHandoverDecision(profile.id);
expect(
decision?.guardians.map((g) => [g.displayName, g.proposal, g.current]),
).toEqual(
expect.arrayContaining([
[expect.stringContaining("second"), "read", "read"],
]),
);
const secondNow = await getPrismaClient().accountGrant.findFirst({
where: { grantorId: profile.id, granteeId: second.id, revokedAt: null },
});
expect(secondNow?.access).toBe("READ");
// Audited as an SSO claim, and the Guardians were told.
const audits = await getPrismaClient().auditLog.findMany({
where: { userId: profile.id },
select: { action: true, details: true },
});
const actions = audits.map((a) => a.action);
expect(actions).toEqual(
expect.arrayContaining([
"managed_profile.claim_sso.started",
"managed_profile.claimed",
"auth.claim",
]),
);
const claimAudit = audits.find((a) => a.action === "auth.claim");
expect(JSON.parse(claimAudit!.details!)).toEqual({ method: "sso" });
// The token never reached an audit row.
expect(JSON.stringify(audits)).not.toContain(token);
expect(sent.map((s) => s.userId).sort()).toEqual(
[creator.id, second.id].sort(),
);
});
it("creates the account without an address the provider did not verify", async () => {
const { creator, profile } = await household();
const token = await mintToken(creator, profile.id);
const cb = await throughProvider(token, {
sub: "subject-unverified",
email: "robin@example.test",
emailVerified: false,
});
expect(landedOn(await callback(cb))).toBe("/onboarding/handover");
const row = await profileRow(profile.id);
expect(row.email).toBeNull();
expect(row.oidcSub).toBe("subject-unverified");
});
it("ignores a caller-supplied next and lands on the fixed page", async () => {
const { creator, profile } = await household();
const token = await mintToken(creator, profile.id);
const started = await startSso(token, "robin");
const claimCookie = setCookie(started, "hl_claim");
const loginResponse = await login(
claimCookie,
"intent=claim&next=https%3A%2F%2Fevil.example%2Fphish",
);
const state = authorize(loginResponse, { sub: "s-next" }, "code-next");
const response = await callback({
url: `${APP}/api/auth/oidc/callback?code=code-next&state=${encodeURIComponent(state)}`,
stateCookie: setCookie(loginResponse, "oidc_auth_state")!,
claimCookie,
});
expect(response.headers.get("location")).toBe(`${APP}/onboarding/handover`);
});
});
describe("refusals", () => {
it("refuses a subject already bound to another account (no double binding)", async () => {
const { creator, profile } = await household();
const holder = await getPrismaClient().user.create({
data: {
username: "holder",
email: "holder@example.test",
oidcIssuer: ISSUER,
oidcSub: "subject-taken",
},
});
const token = await mintToken(creator, profile.id);
const cb = await throughProvider(token, { sub: "subject-taken" });
expect(landedOn(await callback(cb))).toBe(
"/auth/claim?error=claim_sso_subject_bound",
);
await expectUntouched(profile.id);
// The holder keeps their sign-in and is told somebody tried.
expect((await profileRow(holder.id)).oidcSub).toBe("subject-taken");
expect(
await getPrismaClient().auditLog.count({
where: { userId: holder.id, action: "auth.oidc.claim_conflict" },
}),
).toBe(1);
// The link survives for the right person.
expect((await preview(token)).status).toBe(200);
});
it("refuses a second profile for a subject that already claimed one", async () => {
const first = await household();
const second = await household();
const tokenA = await mintToken(first.creator, first.profile.id);
const tokenB = await mintToken(second.creator, second.profile.id);
const a = await throughProvider(tokenA, { sub: "one-person" });
expect(landedOn(await callback(a))).toBe("/onboarding/handover");
cookieJar.clear();
const b = await throughProvider(tokenB, { sub: "one-person" });
expect(landedOn(await callback(b))).toBe(
"/auth/claim?error=claim_sso_subject_bound",
);
await expectUntouched(second.profile.id);
});
it("refuses a verified address another account holds", async () => {
const { creator, profile } = await household();
const token = await mintToken(creator, profile.id);
const cb = await throughProvider(token, {
sub: "subject-email",
email: creator.username + "@EXAMPLE.test",
emailVerified: true,
});
expect(landedOn(await callback(cb))).toBe(
"/auth/claim?error=claim_email_taken",
);
await expectUntouched(profile.id);
});
it("refuses a username taken during the round trip", async () => {
const { creator, profile } = await household();
const token = await mintToken(creator, profile.id);
const cb = await throughProvider(token, { sub: "subject-name" }, "robin");
await getPrismaClient().user.create({
data: { username: "robin", email: "other@example.test" },
});
expect(landedOn(await callback(cb))).toBe(
"/auth/claim?error=claim_username_taken",
);
await expectUntouched(profile.id);
});
it.each([
[
"expired",
async (profileId: string) => {
await getPrismaClient().managedProfileHandover.updateMany({
where: { profileId },
data: { expiresAt: new Date(Date.now() - 1000) },
});
},
],
[
"withdrawn",
async (profileId: string) => {
await getPrismaClient().managedProfileHandover.updateMany({
where: { profileId },
data: { revokedAt: new Date() },
});
},
],
])(
"refuses a link %s while the person was at the provider",
async (_n, spoil) => {
const { creator, profile } = await household();
const token = await mintToken(creator, profile.id);
const cb = await throughProvider(token, { sub: "subject-late" });
await spoil(profile.id);
expect(landedOn(await callback(cb))).toBe(
"/auth/claim?error=claim_expired",
);
await expectUntouched(profile.id);
// Refused before the code was redeemed: nothing spent at the provider.
expect(redeemed).toHaveLength(0);
},
);
it("refuses a link used by a password claim in the meantime", async () => {
oidcEnv(false);
const { creator, profile } = await household();
const token = await mintToken(creator, profile.id);
const cb = await throughProvider(token, { sub: "subject-slow" });
expect((await passwordClaim(token)).status).toBe(201);
cookieJar.clear();
expect(landedOn(await callback(cb))).toBe(
"/auth/claim?error=claim_expired",
);
const row = await profileRow(profile.id);
expect(row.oidcSub).toBeNull();
expect(row.passwordHash).not.toBeNull();
});
it("stops an expired link at the login, before the provider", async () => {
const { creator, profile } = await household();
const token = await mintToken(creator, profile.id);
const started = await startSso(token, "robin");
await getPrismaClient().managedProfileHandover.updateMany({
where: { profileId: profile.id },
data: { expiresAt: new Date(Date.now() - 1000) },
});
const response = await login(setCookie(started, "hl_claim"));
expect(landedOn(response)).toBe("/auth/claim?error=claim_expired");
expect(setCookie(response, "oidc_auth_state")).toBeUndefined();
});
it("answers the start with 404 for a used or unknown link", async () => {
const { creator, profile } = await household();
const token = await mintToken(creator, profile.id);
const cb = await throughProvider(token, { sub: "subject-once" });
expect(landedOn(await callback(cb))).toBe("/onboarding/handover");
cookieJar.clear();
for (const t of [token, `hlp_${"0".repeat(64)}`]) {
const response = await startSso(t, "someone");
expect(response.status).toBe(404);
expect((await response.json()).meta.errorCode).toBe(
"profile_claim.invalid",
);
}
});
});
describe("replay, state, nonce and the claim cookie", () => {
it("does nothing on a replayed callback, even if the provider redeemed the code twice", async () => {
const { creator, profile } = await household();
const token = await mintToken(creator, profile.id);
const cb = await throughProvider(token, {
sub: "subject-replay",
reusable: true,
});
expect(landedOn(await callback(cb))).toBe("/onboarding/handover");
const sessionsAfterFirst = await getPrismaClient().session.count({
where: { userId: profile.id },
});
const sessionCookie = cookieJar.get("healthlog_session");
expect(sessionCookie).toMatch(/^hls_/);
cookieJar.clear();
// The same request again, cookies and all, from a browser without the
// session (a captured request): the link is spent.
expect(landedOn(await callback(cb))).toBe(
"/auth/claim?error=claim_expired",
);
// From the browser that now holds the session: refused as signed in,
// before the link is even looked at.
expect(
landedOn(await callback(cb, { healthlog_session: sessionCookie })),
).toBe("/auth/claim?error=claim_signed_in");
expect(
await getPrismaClient().session.count({ where: { userId: profile.id } }),
).toBe(sessionsAfterFirst);
expect(
await getPrismaClient().auditLog.count({
where: { userId: profile.id, action: "auth.claim" },
}),
).toBe(1);
});
it("refuses a callback whose state does not match", async () => {
const { creator, profile } = await household();
const token = await mintToken(creator, profile.id);
const cb = await throughProvider(token, { sub: "subject-state" });
const forged = cb.url.replace(/state=[^&]+/, "state=forged-state-value");
expect(landedOn(await callback({ ...cb, url: forged }))).toBe(
"/auth/claim?error=oidc_state",
);
await expectUntouched(profile.id);
expect(redeemed).toHaveLength(0);
});
it("refuses an ID token minted for another nonce", async () => {
const { creator, profile } = await household();
const token = await mintToken(creator, profile.id);
const cb = await throughProvider(token, {
sub: "subject-nonce",
nonceOverride: "some-other-sign-in",
});
expect(landedOn(await callback(cb))).toBe("/auth/claim?error=oidc_failed");
await expectUntouched(profile.id);
});
it("refuses a callback that comes back without the claim cookie", async () => {
const { creator, profile } = await household();
const token = await mintToken(creator, profile.id);
const cb = await throughProvider(token, { sub: "subject-nocookie" });
expect(landedOn(await callback({ ...cb, claimCookie: undefined }))).toBe(
"/auth/claim?error=claim_expired",
);
await expectUntouched(profile.id);
expect(redeemed).toHaveLength(0);
});
it("refuses a claim cookie from a different start", async () => {
const { creator, profile } = await household();
const token = await mintToken(creator, profile.id);
const cb = await throughProvider(token, { sub: "subject-swap" });
// A second start in the same browser seals a new binding; the sign-in
// that began with the first one cannot finish with it.
const other = setCookie(await startSso(token, "robin2"), "hl_claim");
expect(landedOn(await callback({ ...cb, claimCookie: other }))).toBe(
"/auth/claim?error=claim_expired",
);
await expectUntouched(profile.id);
});
it("refuses a forged claim cookie", async () => {
const { creator, profile } = await household();
const token = await mintToken(creator, profile.id);
const cb = await throughProvider(token, { sub: "subject-forged" });
const link =
await getPrismaClient().managedProfileHandover.findFirstOrThrow({
where: { profileId: profile.id },
});
const forged = Buffer.from(
JSON.stringify({ h: link.id, u: "x", b: "y", e: Date.now() + 1e6 }),
).toString("base64");
expect(landedOn(await callback({ ...cb, claimCookie: forged }))).toBe(
"/auth/claim?error=claim_expired",
);
await expectUntouched(profile.id);
});
it("does not adopt a session id the browser brought along", async () => {
const { creator, profile } = await household();
const token = await mintToken(creator, profile.id);
const cb = await throughProvider(token, { sub: "subject-fixation" });
const planted = "planted-session-id-chosen-by-somebody-else";
expect(landedOn(await callback(cb, { healthlog_session: planted }))).toBe(
"/onboarding/handover",
);
const sessions = await getPrismaClient().session.findMany({
where: { userId: profile.id },
select: { id: true },
});
expect(sessions).toHaveLength(1);
expect(sessions[0]!.id).not.toBe(planted);
expect(cookieJar.get("healthlog_session")).not.toBe(planted);
});
});
describe("which ways in a server offers", () => {
it("offers both with a provider and passwords allowed, and both work", async () => {
oidcEnv(false);
const { creator, profile } = await household();
const status = (await (await handoverStatus(creator, profile.id)).json())
.data;
expect(status.methods).toEqual(["password", "sso"]);
const token = await mintToken(creator, profile.id);
expect((await (await preview(token)).json()).data.methods).toEqual([
"password",
"sso",
]);
const cb = await throughProvider(token, { sub: "subject-mixed" });
expect(landedOn(await callback(cb))).toBe("/onboarding/handover");
const other = await household();
const token2 = await mintToken(other.creator, other.profile.id);
expect((await passwordClaim(token2)).status).toBe(201);
});
it("offers only the password without a provider, and the SSO start says so", async () => {
for (const key of ["OIDC_ISSUER_URL", "OIDC_CLIENT_ID", "OIDC_ONLY"]) {
delete process.env[key];
}
const { creator, profile } = await household();
const status = (await (await handoverStatus(creator, profile.id)).json())
.data;
expect(status.methods).toEqual(["password"]);
const token = await mintToken(creator, profile.id);
const shown = (await (await preview(token)).json()).data;
expect(shown.methods).toEqual(["password"]);
expect(shown.ssoProviderName).toBeNull();
const response = await startSso(token, "robin");
expect(response.status).toBe(404);
expect((await response.json()).meta.errorCode).toBe(
"profile_claim.sso_unavailable",
);
});
});
@@ -18,7 +18,8 @@
* - the races that matter: two claims, a claim against a new link, a claim
* against the profile's deletion;
* - the anonymous surface: uniform 404, live-session 409, rate limit, and
* the refusal on a single-sign-on-only instance;
* the password claim's refusal on a single-sign-on-only instance (the
* single-sign-on claim has its own file);
* - the new owner's decision afterwards, including a restored MANAGE and a
* row that moved since the claim and is left alone.
*/
@@ -811,7 +812,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",
@@ -862,7 +863,7 @@ describe("claiming the profile", () => {
}
});
it("refuses over a live session and on a single-sign-on-only instance", async () => {
it("refuses over a live session, and the password claim on a single-sign-on-only instance", async () => {
const { creator, profile } = await household();
signIn(creator);
const token = await mintToken(profile.id);
@@ -877,7 +878,9 @@ describe("claiming the profile", () => {
process.env.OIDC_CLIENT_ID = "client";
process.env.OIDC_CLIENT_SECRET = "secret";
process.env.OIDC_ONLY = "true";
expect((await mint(profile.id)).status).toBe(403);
// v1.43 (#959): the handover itself works there, through the identity
// provider (tests/integration/managed-profile-handover-sso.test.ts); only
// the password claim stays closed.
const status = await (
await (
await import("@/app/api/managed-profiles/[id]/handover/route")
@@ -888,14 +891,17 @@ describe("claiming the profile", () => {
{ params: Promise.resolve({ id: profile.id }) },
)
).json();
expect(status.data.available).toBe(false);
expect(status.data.available).toBe(true);
expect(status.data.methods).toEqual(["sso"]);
signOut();
for (const response of [await preview(token), await claim(token)]) {
expect(response.status).toBe(403);
expect((await response.json()).meta.errorCode).toBe(
"profile_claim.oidc_only_unsupported",
);
}
const shown = await preview(token);
expect(shown.status).toBe(200);
expect((await shown.json()).data.methods).toEqual(["sso"]);
const refused = await claim(token);
expect(refused.status).toBe(403);
expect((await refused.json()).meta.errorCode).toBe(
"profile_claim.oidc_only_unsupported",
);
// The profile is still managed: nothing was taken over.
const account = await getPrismaClient().user.findUniqueOrThrow({
where: { id: profile.id },