mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
commit
5c039a220b
39 files changed
+2649
-365
No files matched your search
+194
-178
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"] })
|
||||
|
||||
@@ -100,9 +100,11 @@ describe("S1 — the direct session-resolution set is frozen", () => {
|
||||
"app/api/auth/claim/sso/route.ts":
|
||||
"refuses to start a single-sign-on claim over a live session",
|
||||
// Anonymous IdP callback. Probes for an already-valid session so a
|
||||
// replayed callback redirects instead of minting a second identity.
|
||||
// replayed callback redirects instead of minting a second identity, and
|
||||
// (v1.43, #959) so a single-sign-on claim is refused over a live session,
|
||||
// as the claim routes refuse it.
|
||||
"app/api/auth/oidc/callback/route.ts":
|
||||
"duplicate-callback probe on an otherwise anonymous route",
|
||||
"duplicate-callback probe and the claim's live-session refusal on an otherwise anonymous route",
|
||||
// Public liveness probe. The session read only decides whether the body
|
||||
// carries operational detail; the status code is computed without it.
|
||||
"app/api/health/route.ts": "admin-only detail on a public probe",
|
||||
@@ -370,6 +372,48 @@ describe("S2 — the URL-token authentication set is frozen", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("S4 — the single-sign-on claim cookie set is frozen", () => {
|
||||
/**
|
||||
* v1.43 (#959). A third way to act without a session: the sealed `hl_claim`
|
||||
* cookie carries a managed-profile claim from `POST /api/auth/claim/sso`
|
||||
* through the OIDC round trip, and the callback turns it — together with
|
||||
* the provider's subject — into somebody's account. It is neither a session
|
||||
* nor a URL credential, so neither leg above sees it.
|
||||
*
|
||||
* The handover token itself still only travels in the claim routes' bodies
|
||||
* and the claim page's query string (S2); the cookie names the link by id.
|
||||
* What this freezes is who may mint the cookie and who may act on it.
|
||||
*/
|
||||
const CLAIM_COOKIE_ALLOWLIST: Record<string, string> = {
|
||||
"app/api/auth/claim/sso/route.ts":
|
||||
"mints the cookie, only after checking the token and the username",
|
||||
"app/api/auth/oidc/login/route.ts":
|
||||
"requires the cookie for intent=claim and binds it into the OIDC state",
|
||||
"app/api/auth/oidc/callback/route.ts":
|
||||
"binds the provider's subject to the profile the cookie names",
|
||||
};
|
||||
|
||||
const CLAIM_COOKIE_USE =
|
||||
/\b(readClaimCookie|setClaimCookie|claimMatchesBinding|clearClaimCookie)\s*\(|\bCLAIM_COOKIE\b|["']hl_claim["']/;
|
||||
|
||||
it("no file under src/app touches the claim cookie unlisted", () => {
|
||||
const users = appFilesMatching(CLAIM_COOKIE_USE);
|
||||
expect(users.length).toBeGreaterThan(0);
|
||||
expect(users).toEqual(Object.keys(CLAIM_COOKIE_ALLOWLIST).sort());
|
||||
});
|
||||
|
||||
it("only the single-sign-on start mints it", () => {
|
||||
const minters = appFilesMatching(/\bsetClaimCookie\s*\(/);
|
||||
expect(minters).toEqual(["app/api/auth/claim/sso/route.ts"]);
|
||||
});
|
||||
|
||||
it("every allowlist entry carries a reason", () => {
|
||||
for (const [rel, reason] of Object.entries(CLAIM_COOKIE_ALLOWLIST)) {
|
||||
expect(reason.trim().length, `${rel} has no reason`).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("S3 — the two families stay distinct", () => {
|
||||
/**
|
||||
* A file that both resolves a session AND authenticates by a URL token is
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
* (`tests/integration/managed-profile-handover.test.ts`); here the order of
|
||||
* the refusals is pinned, because each one is a security property:
|
||||
*
|
||||
* - single sign-on only refuses before anything else;
|
||||
* - single sign-on only refuses the PASSWORD claim before anything else,
|
||||
* while the preview answers there and names `sso` as the way in (v1.43);
|
||||
* - the single-sign-on start needs a configured provider, refuses a live
|
||||
* session before the rate limit, checks the link before the username,
|
||||
* and seals the link's id (never the token) into `hl_claim`;
|
||||
* - a live session is refused BEFORE the rate limit (a state, not an
|
||||
* attempt), and before any token is read;
|
||||
* - the rate limit runs before the body is parsed;
|
||||
@@ -24,7 +28,18 @@ vi.mock("@/lib/auth/session", () => ({
|
||||
createSession: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/auth/oidc", () => ({ isOidcOnly: vi.fn() }));
|
||||
vi.mock("@/lib/auth/oidc", () => ({
|
||||
isOidcOnly: vi.fn(),
|
||||
isOidcConfigured: vi.fn(),
|
||||
getOidcConfig: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((s: string) => `sealed(${s})`),
|
||||
decrypt: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/auth/secure-cookie", () => ({
|
||||
shouldEmitSecureCookie: () => true,
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
@@ -49,6 +64,7 @@ vi.mock("@/lib/managed-profiles/handover", async () => {
|
||||
return {
|
||||
HandoverError: actual.HandoverError,
|
||||
previewHandover: vi.fn(),
|
||||
usableHandoverForToken: vi.fn(),
|
||||
claimManagedProfile: vi.fn(),
|
||||
};
|
||||
});
|
||||
@@ -67,9 +83,11 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
import { POST as claim } from "../route";
|
||||
import { POST as preview } from "../preview/route";
|
||||
import { POST as claimSso } from "../sso/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createSession, getSession } from "@/lib/auth/session";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
import { getOidcConfig, isOidcConfigured, isOidcOnly } from "@/lib/auth/oidc";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { checkPasswordStrength, hashPassword } from "@/lib/auth/password";
|
||||
import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check";
|
||||
@@ -77,6 +95,7 @@ import {
|
||||
claimManagedProfile,
|
||||
HandoverError,
|
||||
previewHandover,
|
||||
usableHandoverForToken,
|
||||
} from "@/lib/managed-profiles/handover";
|
||||
import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify";
|
||||
|
||||
@@ -105,6 +124,12 @@ const PREVIEW = {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(isOidcOnly).mockReturnValue(false);
|
||||
vi.mocked(isOidcConfigured).mockReturnValue(false);
|
||||
vi.mocked(getOidcConfig).mockReturnValue(null);
|
||||
vi.mocked(usableHandoverForToken).mockResolvedValue({
|
||||
handoverId: "h1",
|
||||
profileId: "p1",
|
||||
});
|
||||
vi.mocked(getSession).mockResolvedValue(null as never);
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
@@ -131,19 +156,62 @@ async function errorCode(res: Response): Promise<unknown> {
|
||||
return body.meta?.errorCode;
|
||||
}
|
||||
|
||||
const OIDC_CONFIG = {
|
||||
issuerUrl: "https://idp.example.test",
|
||||
clientId: "c",
|
||||
clientSecret: "s",
|
||||
scopes: "openid email",
|
||||
buttonLabel: "Example ID",
|
||||
};
|
||||
|
||||
function singleSignOnOnly(): void {
|
||||
vi.mocked(isOidcOnly).mockReturnValue(true);
|
||||
vi.mocked(isOidcConfigured).mockReturnValue(true);
|
||||
vi.mocked(getOidcConfig).mockReturnValue(OIDC_CONFIG);
|
||||
}
|
||||
|
||||
describe("on a single-sign-on-only instance", () => {
|
||||
it("refuses the password claim before anything else", async () => {
|
||||
singleSignOnOnly();
|
||||
vi.mocked(getSession).mockResolvedValue({ user: { id: "u1" } } as never);
|
||||
const res = await claim(post("/api/auth/claim", BODY));
|
||||
expect(res.status).toBe(403);
|
||||
expect(await errorCode(res)).toBe("profile_claim.oidc_only_unsupported");
|
||||
expect(previewHandover).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers the preview and names single sign-on as the only way in", async () => {
|
||||
singleSignOnOnly();
|
||||
const res = await preview(
|
||||
post("/api/auth/claim/preview", { token: TOKEN }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data.methods).toEqual(["sso"]);
|
||||
expect(body.data.ssoProviderName).toBe("Example ID");
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["claim", claim, "/api/auth/claim", BODY],
|
||||
["preview", preview, "/api/auth/claim/preview", { token: TOKEN }],
|
||||
["sso", claimSso, "/api/auth/claim/sso", { token: TOKEN, username: "alex" }],
|
||||
] as const)(
|
||||
"POST %s — refusals that read no token",
|
||||
(_n, route, path, body) => {
|
||||
it("refuses on a single-sign-on-only instance before anything else", async () => {
|
||||
vi.mocked(isOidcOnly).mockReturnValue(true);
|
||||
vi.mocked(getSession).mockResolvedValue({ user: { id: "u1" } } as never);
|
||||
const res = await route(post(path, body));
|
||||
expect(res.status).toBe(403);
|
||||
expect(await errorCode(res)).toBe("profile_claim.oidc_only_unsupported");
|
||||
expect(previewHandover).not.toHaveBeenCalled();
|
||||
(name, route, path, body) => {
|
||||
beforeEach(() => {
|
||||
if (name === "sso") {
|
||||
vi.mocked(isOidcConfigured).mockReturnValue(true);
|
||||
vi.mocked(getOidcConfig).mockReturnValue(OIDC_CONFIG);
|
||||
}
|
||||
if (name === "sso") {
|
||||
vi.mocked(usableHandoverForToken).mockImplementation(async () => {
|
||||
// Routes the sso start through the same "link" spy the other two
|
||||
// use, so one set of assertions covers all three.
|
||||
const found = await previewHandover(TOKEN);
|
||||
return found ? { handoverId: "h1", profileId: "p1" } : null;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses over a live session before spending the rate limit", async () => {
|
||||
@@ -234,7 +302,7 @@ describe("POST /api/auth/claim", () => {
|
||||
expect(res.status).toBe(201);
|
||||
expect(claimManagedProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rawToken: TOKEN,
|
||||
link: { rawToken: TOKEN },
|
||||
username: "alex",
|
||||
email: "alex@example.test",
|
||||
passwordHash: "argon2-hash",
|
||||
@@ -260,7 +328,101 @@ describe("POST /api/auth/claim/preview", () => {
|
||||
"displayName",
|
||||
"expiresAt",
|
||||
"guardians",
|
||||
"methods",
|
||||
"ssoProviderName",
|
||||
]);
|
||||
expect(body.data.methods).toEqual(["password"]);
|
||||
expect(body.data.ssoProviderName).toBeNull();
|
||||
expect(body.data.expiresAt).toBe("2030-01-01T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/claim/sso", () => {
|
||||
const SSO_BODY = { token: TOKEN, username: "alex" };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(isOidcConfigured).mockReturnValue(true);
|
||||
vi.mocked(getOidcConfig).mockReturnValue(OIDC_CONFIG);
|
||||
});
|
||||
|
||||
it("answers 404 sso_unavailable without an identity provider, before anything else", async () => {
|
||||
vi.mocked(isOidcConfigured).mockReturnValue(false);
|
||||
vi.mocked(getSession).mockResolvedValue({ user: { id: "u1" } } as never);
|
||||
const res = await claimSso(post("/api/auth/claim/sso", SSO_BODY));
|
||||
expect(res.status).toBe(404);
|
||||
expect(await errorCode(res)).toBe("profile_claim.sso_unavailable");
|
||||
expect(checkAuthSurfaceRateLimit).not.toHaveBeenCalled();
|
||||
expect(usableHandoverForToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("works on a single-sign-on-only instance", async () => {
|
||||
singleSignOnOnly();
|
||||
const res = await claimSso(post("/api/auth/claim/sso", SSO_BODY));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("draws on the password claim's bucket", async () => {
|
||||
await claimSso(post("/api/auth/claim/sso", SSO_BODY));
|
||||
expect(checkAuthSurfaceRateLimit).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"auth:claim",
|
||||
5,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
|
||||
it("checks the link before the username probe", async () => {
|
||||
vi.mocked(usableHandoverForToken).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: "x" } as never);
|
||||
const res = await claimSso(post("/api/auth/claim/sso", SSO_BODY));
|
||||
expect(res.status).toBe(404);
|
||||
expect(await errorCode(res)).toBe("profile_claim.invalid");
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
expect(res.headers.get("set-cookie")).toBeNull();
|
||||
});
|
||||
|
||||
it("answers 409 taken for a username in use, and sets no cookie", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: "x" } as never);
|
||||
const res = await claimSso(post("/api/auth/claim/sso", SSO_BODY));
|
||||
expect(res.status).toBe(409);
|
||||
expect(await errorCode(res)).toBe("profile_claim.taken");
|
||||
expect(res.headers.get("set-cookie")).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses an unknown field and a managed- username (strict body)", async () => {
|
||||
for (const body of [
|
||||
{ ...SSO_BODY, email: "x@example.test" },
|
||||
{ ...SSO_BODY, username: "managed-alex" },
|
||||
]) {
|
||||
const res = await claimSso(post("/api/auth/claim/sso", body));
|
||||
expect(res.status).toBe(422);
|
||||
}
|
||||
expect(usableHandoverForToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("seals the link's id and the username, never the token, into hl_claim", async () => {
|
||||
const res = await claimSso(post("/api/auth/claim/sso", SSO_BODY));
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data).toEqual({
|
||||
redirectTo: "/api/auth/oidc/login?intent=claim",
|
||||
});
|
||||
const cookie = res.headers.get("set-cookie") ?? "";
|
||||
expect(cookie).toMatch(/^hl_claim=/);
|
||||
expect(cookie).toMatch(/Path=\/api\/auth\/oidc/);
|
||||
expect(cookie).toMatch(/HttpOnly/i);
|
||||
expect(cookie).toMatch(/Secure/i);
|
||||
expect(cookie).toMatch(/SameSite=lax/i);
|
||||
expect(cookie).toMatch(/Max-Age=600/);
|
||||
const sealed = decodeURIComponent(cookie.split(";")[0]!.slice(9));
|
||||
const payload = JSON.parse(sealed.replace(/^sealed\(/, "").slice(0, -1));
|
||||
expect(payload.h).toBe("h1");
|
||||
expect(payload.u).toBe("alex");
|
||||
expect(typeof payload.b).toBe("string");
|
||||
expect(cookie).not.toContain(TOKEN);
|
||||
expect(JSON.stringify(vi.mocked(auditLog).mock.calls)).not.toContain(TOKEN);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"managed_profile.claim_sso.started",
|
||||
expect.objectContaining({ userId: "p1", details: { handoverId: "h1" } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@
|
||||
* access (v1.42, #959). No health data.
|
||||
*
|
||||
* Anonymous by design: the person opening the link has no credentials yet.
|
||||
* Answered on a single-sign-on-only server too (v1.43): `methods` then names
|
||||
* only `sso`, and the page offers the identity provider.
|
||||
* The one-time `hlp_` token travels in the BODY, never in this request's URL,
|
||||
* so it never reaches an access log or a wide event's `http.path`. Every
|
||||
* unusable link — malformed, unknown, expired, used, withdrawn, a profile no
|
||||
@@ -15,7 +17,12 @@ import { apiHandler } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { claimGate, claimInvalid } from "@/lib/managed-profiles/claim-gate";
|
||||
import {
|
||||
claimGate,
|
||||
claimInvalid,
|
||||
handoverMethods,
|
||||
ssoProviderName,
|
||||
} from "@/lib/managed-profiles/claim-gate";
|
||||
import { previewHandover } from "@/lib/managed-profiles/handover";
|
||||
import { claimPreviewSchema } from "@/lib/validations/managed-profile-handover";
|
||||
|
||||
@@ -46,5 +53,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
displayName: preview.displayName,
|
||||
expiresAt: preview.expiresAt.toISOString(),
|
||||
guardians: preview.guardians,
|
||||
// v1.43 (#959) — which ways in this server offers, so the page shows the
|
||||
// password form, the provider button, or both.
|
||||
methods: handoverMethods(),
|
||||
ssoProviderName: ssoProviderName(),
|
||||
});
|
||||
});
|
||||
@@ -104,7 +104,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
let claimed;
|
||||
try {
|
||||
claimed = await claimManagedProfile({
|
||||
rawToken: token,
|
||||
link: { rawToken: token },
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
|
||||
@@ -1,29 +1,47 @@
|
||||
/**
|
||||
* `POST /api/auth/claim/sso` — start claiming a managed profile through
|
||||
* single sign-on (#959).
|
||||
* single sign-on (v1.43, #959).
|
||||
*
|
||||
* Anonymous, like the password claim beside it. `{ token, username }`: the
|
||||
* link is checked and the username reserved for the round trip, a sealed
|
||||
* `hl_claim` cookie (10 minutes, `httpOnly`, `Secure` through
|
||||
* `shouldEmitSecureCookie()`) carries `{ handoverId, username, exp }`, and the
|
||||
* answer names where to go: `{ redirectTo: "/api/auth/oidc/login?intent=claim" }`.
|
||||
* The OIDC callback binds the identity provider's subject to the profile. The
|
||||
* token never rides a URL. Works on a single-sign-on-only server; 404
|
||||
* `profile_claim.sso_unavailable` when no identity provider is configured.
|
||||
* Anonymous, like the password claim beside it, and ordered like it:
|
||||
*
|
||||
* 1. refusals that read no token (`claimGate`: no identity provider, a live
|
||||
* session, the per-address bucket the password claim also draws on);
|
||||
* 2. the body, strictly parsed, and the link checked;
|
||||
* 3. the username probe, so the person learns a name is taken here rather
|
||||
* than after the round trip to the provider.
|
||||
*
|
||||
* Nothing about the account changes here. The answer is a sealed `hl_claim`
|
||||
* cookie (`claim-cookie.ts`) naming the link by its id and carrying the
|
||||
* chosen username, and `{ redirectTo }` for the browser to navigate to. The
|
||||
* OIDC callback binds the provider's subject to the profile, under the lock,
|
||||
* with every check repeated. The token never rides a URL and never reaches the
|
||||
* cookie.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { claimGate } from "@/lib/managed-profiles/claim-gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
CLAIM_SSO_REDIRECT,
|
||||
setClaimCookie,
|
||||
} from "@/lib/managed-profiles/claim-cookie";
|
||||
import { claimGate, claimInvalid } from "@/lib/managed-profiles/claim-gate";
|
||||
import { usableHandoverForToken } from "@/lib/managed-profiles/handover";
|
||||
import { claimSsoSchema } from "@/lib/validations/claim-sso";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// Refuses a signed-in caller and rate-limits the address, as the password
|
||||
// claim does.
|
||||
const gate = await claimGate(request, "claim", (await getSession()) !== null);
|
||||
const gate = await claimGate(request, "sso", (await getSession()) !== null);
|
||||
if (gate.refusal) return gate.refusal;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
@@ -32,6 +50,43 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = claimSsoSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
const { token, username } = parsed.data;
|
||||
|
||||
return notImplemented("profile_claim.sso.start");
|
||||
// The link first, so the username probe below is no oracle for anybody
|
||||
// without a live link (the password claim's argument).
|
||||
const link = await usableHandoverForToken(token);
|
||||
if (!link) {
|
||||
annotate({
|
||||
action: { name: "profile_claim.sso.start" },
|
||||
meta: { found: false },
|
||||
});
|
||||
return claimInvalid();
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
return apiError("Username already taken", 409, {
|
||||
errorCode: "profile_claim.taken",
|
||||
});
|
||||
}
|
||||
|
||||
const response = apiSuccess({ redirectTo: CLAIM_SSO_REDIRECT });
|
||||
setClaimCookie(response, { handoverId: link.handoverId, username });
|
||||
|
||||
// Filed under the record, with no actor: nobody is signed in yet. No token,
|
||||
// no username — the username is not the person's until the claim lands.
|
||||
await auditLog("managed_profile.claim_sso.started", {
|
||||
userId: link.profileId,
|
||||
actorUserId: null,
|
||||
ipAddress: gate.ip,
|
||||
details: { handoverId: link.handoverId },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "profile_claim.sso.start" },
|
||||
meta: { found: true },
|
||||
});
|
||||
return response;
|
||||
});
|
||||
@@ -38,6 +38,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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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, {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* v1.43 (#959) — the sealed cookie that carries a single-sign-on claim through
|
||||
* the round trip to the identity provider.
|
||||
*
|
||||
* `POST /api/auth/claim/sso` checks the handover token and the chosen username
|
||||
* and seals `{ handoverId, username, binding, exp }` into `hl_claim`. The
|
||||
* token itself never leaves that request: the cookie names the link by its
|
||||
* id, and it can only have been minted by a request that held the token.
|
||||
*
|
||||
* - AES-256-GCM with the at-rest key (`encrypt`), so it can be neither read
|
||||
* nor forged, and it carries its own expiry on top of `Max-Age`.
|
||||
* - `HttpOnly`, `SameSite=Lax` (the browser returns from the provider on a
|
||||
* cross-site top-level GET and must carry it), `Secure` through
|
||||
* `shouldEmitSecureCookie()`.
|
||||
* - Scoped to `/api/auth/oidc`, the only two routes that read it.
|
||||
* - `binding` is a fresh random value per start. The login copies it into
|
||||
* the encrypted OIDC state, and the callback admits the claim only when
|
||||
* the cookie and the state carry the same one, so a claim cookie from a
|
||||
* different start (another tab, an older attempt) cannot be paired with
|
||||
* this sign-in.
|
||||
*/
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import type { NextResponse } from "next/server";
|
||||
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
|
||||
export const CLAIM_COOKIE = "hl_claim";
|
||||
export const CLAIM_COOKIE_PATH = "/api/auth/oidc";
|
||||
export const CLAIM_COOKIE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Where `POST /api/auth/claim/sso` sends the browser next. A fixed path:
|
||||
* nothing caller-supplied ever reaches a redirect on this flow.
|
||||
*/
|
||||
export const CLAIM_SSO_REDIRECT = "/api/auth/oidc/login?intent=claim";
|
||||
|
||||
/** Where every refusal on the single-sign-on claim lands, with `?error=`. */
|
||||
export const CLAIM_ERROR_PATH = "/auth/claim";
|
||||
|
||||
export interface PendingSsoClaim {
|
||||
handoverId: string;
|
||||
username: string;
|
||||
binding: string;
|
||||
}
|
||||
|
||||
/** Attach a freshly sealed claim to a response. Returns what was sealed. */
|
||||
export function setClaimCookie(
|
||||
response: NextResponse,
|
||||
claim: { handoverId: string; username: string },
|
||||
now: number = Date.now(),
|
||||
): PendingSsoClaim {
|
||||
const binding = randomBytes(32).toString("base64url");
|
||||
const sealed = encrypt(
|
||||
JSON.stringify({
|
||||
h: claim.handoverId,
|
||||
u: claim.username,
|
||||
b: binding,
|
||||
e: now + CLAIM_COOKIE_TTL_MS,
|
||||
}),
|
||||
);
|
||||
response.cookies.set(CLAIM_COOKIE, sealed, {
|
||||
httpOnly: true,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
sameSite: "lax",
|
||||
maxAge: Math.floor(CLAIM_COOKIE_TTL_MS / 1000),
|
||||
path: CLAIM_COOKIE_PATH,
|
||||
});
|
||||
return { ...claim, binding };
|
||||
}
|
||||
|
||||
/** The claim a cookie value carries, or null when absent, forged or expired. */
|
||||
export function readClaimCookie(
|
||||
raw: string | undefined,
|
||||
now: number = Date.now(),
|
||||
): PendingSsoClaim | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(decrypt(raw)) as Record<string, unknown>;
|
||||
if (
|
||||
typeof parsed.h !== "string" ||
|
||||
parsed.h.length === 0 ||
|
||||
typeof parsed.u !== "string" ||
|
||||
parsed.u.length === 0 ||
|
||||
typeof parsed.b !== "string" ||
|
||||
parsed.b.length === 0 ||
|
||||
typeof parsed.e !== "number" ||
|
||||
parsed.e <= now
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { handoverId: parsed.h, username: parsed.u, binding: parsed.b };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a claim belongs to the sign-in whose state carries `binding`. */
|
||||
export function claimMatchesBinding(
|
||||
claim: PendingSsoClaim,
|
||||
binding: string,
|
||||
): boolean {
|
||||
const a = Buffer.from(claim.binding);
|
||||
const b = Buffer.from(binding);
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/** Expire the claim cookie on a response; the path must repeat the set's. */
|
||||
export function clearClaimCookie(response: NextResponse): void {
|
||||
response.cookies.delete({ name: CLAIM_COOKIE, path: CLAIM_COOKIE_PATH });
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* v1.42 (#959) — the refusals both anonymous claim routes give before they
|
||||
* look at a token, in one place so the preview and the claim cannot disagree.
|
||||
* v1.42 (#959) — the refusals the anonymous claim routes give before they look
|
||||
* at a token, in one place so the preview and the claims cannot disagree.
|
||||
*
|
||||
* Order matters and is the register route's order:
|
||||
*
|
||||
* 1. A single-sign-on-only instance admits no password, and the claim sets
|
||||
* one, so the handover is refused outright in v1.42.
|
||||
* 1. What this server cannot do at all. The password claim sets a password,
|
||||
* so a single-sign-on-only instance refuses it outright; the
|
||||
* single-sign-on claim (v1.43) needs an identity provider, and answers
|
||||
* 404 `profile_claim.sso_unavailable` without one. The preview is
|
||||
* refused by neither: it is what tells the page which way is open.
|
||||
* 2. A signed-in caller is refused with 409. Claiming creates a session for
|
||||
* the new owner, and a Guardian testing their own link would otherwise be
|
||||
* signed out of their account and into the person's. Refused before the
|
||||
@@ -13,12 +16,14 @@
|
||||
* would lock out the person the link is for, who may share the address.
|
||||
* 3. The per-address rate limit, the anonymous auth-surface pattern: a
|
||||
* misconfigured proxy trust chain collapses every caller into one tight
|
||||
* bucket rather than one free-for-all.
|
||||
* bucket rather than one free-for-all. Both claims share one bucket, so
|
||||
* switching between them buys no extra attempts.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
import { getOidcConfig, isOidcConfigured, isOidcOnly } from "@/lib/auth/oidc";
|
||||
import type { HandoverMethod } from "@/lib/validations/claim-sso";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
|
||||
@@ -29,9 +34,28 @@ export function claimInvalid() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.43 (#959) — how a handover link can be redeemed on this server: with a
|
||||
* password unless the server allows single sign-on only, and through the
|
||||
* identity provider whenever one is configured. Never empty in practice
|
||||
* (`OIDC_ONLY` only takes effect with a configured provider), but the Guardian
|
||||
* side refuses on an empty list rather than assuming it.
|
||||
*/
|
||||
export function handoverMethods(): HandoverMethod[] {
|
||||
const methods: HandoverMethod[] = [];
|
||||
if (!isOidcOnly()) methods.push("password");
|
||||
if (isOidcConfigured()) methods.push("sso");
|
||||
return methods;
|
||||
}
|
||||
|
||||
/** The provider's name for the "Continue with …" button, or null. */
|
||||
export function ssoProviderName(): string | null {
|
||||
return getOidcConfig()?.buttonLabel ?? null;
|
||||
}
|
||||
|
||||
export async function claimGate(
|
||||
request: Request,
|
||||
surface: "preview" | "claim",
|
||||
surface: "preview" | "claim" | "sso",
|
||||
/**
|
||||
* Whether the request carries a live session. Resolved by the ROUTE with
|
||||
* `getSession()` and handed in, rather than resolved here: a session read
|
||||
@@ -41,15 +65,24 @@ export async function claimGate(
|
||||
*/
|
||||
signedIn: boolean,
|
||||
): Promise<{ refusal: Response } | { refusal: null; ip: string | null }> {
|
||||
if (isOidcOnly()) {
|
||||
if (surface === "claim" && isOidcOnly()) {
|
||||
return {
|
||||
refusal: apiError(
|
||||
"Taking over a profile is not available while this server allows single sign-on only",
|
||||
"This server allows single sign-on only; take the profile over through the identity provider",
|
||||
403,
|
||||
{ errorCode: "profile_claim.oidc_only_unsupported" },
|
||||
),
|
||||
};
|
||||
}
|
||||
if (surface === "sso" && !isOidcConfigured()) {
|
||||
return {
|
||||
refusal: apiError(
|
||||
"Single sign-on is not configured on this server",
|
||||
404,
|
||||
{ errorCode: "profile_claim.sso_unavailable" },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (signedIn) {
|
||||
annotate({
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* v1.43 (#959) — the callback's half of claiming a managed profile through
|
||||
* single sign-on: the identity provider has authenticated somebody, the
|
||||
* sealed claim cookie names the link and the chosen username, and this binds
|
||||
* the one to the other.
|
||||
*
|
||||
* The refusals, in order, each one before anything about any account changes:
|
||||
*
|
||||
* 1. `claim_expired` — the link is no longer usable (used, withdrawn,
|
||||
* replaced, expired, its creator gone, the profile no longer managed).
|
||||
* 2. `claim_sso_subject_bound` — the provider's `(issuer, sub)` already
|
||||
* signs in to an account here. One subject, one account: binding it a
|
||||
* second time would make the sign-in ambiguous, and re-pointing it would
|
||||
* hand the existing account's sign-in to the claimed profile.
|
||||
* 3. `claim_email_taken` — the provider vouches (`email_verified: true`) for
|
||||
* an address another account holds. An unverified address is not used at
|
||||
* all: the account is created without one rather than with a claim
|
||||
* nobody checked.
|
||||
* 4. `claim_username_taken` — the username chosen at the start was taken in
|
||||
* the ten minutes since.
|
||||
*
|
||||
* Then `claimManagedProfile` — the password claim's own transaction, under
|
||||
* the managed-profile lock, with every link check repeated — sets the
|
||||
* username, the address, no password and the subject; applies each Guardian's
|
||||
* proposed access; resets disclaimer, onboarding and AI consent. A race lost
|
||||
* at the unique indexes is re-read and answered as the refusal it turned out
|
||||
* to be. The session is minted only after that commit.
|
||||
*/
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { recordSignInDevice } from "@/lib/auth/login-alert";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
claimManagedProfile,
|
||||
HandoverError,
|
||||
isHandoverUsable,
|
||||
} from "@/lib/managed-profiles/handover";
|
||||
import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify";
|
||||
|
||||
export type SsoClaimRefusal =
|
||||
| "claim_expired"
|
||||
| "claim_sso_subject_bound"
|
||||
| "claim_email_taken"
|
||||
| "claim_username_taken";
|
||||
|
||||
export type SsoClaimOutcome =
|
||||
{ ok: true; profileId: string } | { ok: false; reason: SsoClaimRefusal };
|
||||
|
||||
/** Where the new owner lands: the decision about each former Guardian. */
|
||||
export const SSO_CLAIM_LANDING = "/onboarding/handover";
|
||||
|
||||
interface SsoClaimInput {
|
||||
handoverId: string;
|
||||
username: string;
|
||||
issuer: string;
|
||||
sub: string;
|
||||
email: string | null;
|
||||
emailVerified: boolean | undefined;
|
||||
ipAddress: string;
|
||||
userAgent: string | null;
|
||||
}
|
||||
|
||||
/** Which of the three unique values is held by an account already. */
|
||||
async function collision(input: {
|
||||
issuer: string;
|
||||
sub: string;
|
||||
email: string | null;
|
||||
username: string;
|
||||
}): Promise<SsoClaimRefusal | null> {
|
||||
const bound = await prisma.user.findFirst({
|
||||
where: { oidcIssuer: input.issuer, oidcSub: input.sub },
|
||||
select: { id: true },
|
||||
});
|
||||
if (bound) {
|
||||
// Under the account that holds the subject, so its owner's history shows
|
||||
// that somebody tried to bind their sign-in to a second record.
|
||||
await auditLog("auth.oidc.claim_conflict", {
|
||||
userId: bound.id,
|
||||
actorUserId: null,
|
||||
});
|
||||
return "claim_sso_subject_bound";
|
||||
}
|
||||
if (input.email) {
|
||||
const byEmail = await prisma.user.findFirst({
|
||||
where: { email: { equals: input.email, mode: "insensitive" } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (byEmail) return "claim_email_taken";
|
||||
}
|
||||
const byName = await prisma.user.findUnique({
|
||||
where: { username: input.username },
|
||||
select: { id: true },
|
||||
});
|
||||
return byName ? "claim_username_taken" : null;
|
||||
}
|
||||
|
||||
export async function completeSsoClaim(
|
||||
input: SsoClaimInput,
|
||||
): Promise<SsoClaimOutcome> {
|
||||
const refuse = (reason: SsoClaimRefusal): SsoClaimOutcome => {
|
||||
annotate({
|
||||
action: { name: "profile_claim.sso.refused" },
|
||||
meta: { reason },
|
||||
});
|
||||
return { ok: false, reason };
|
||||
};
|
||||
|
||||
if (!(await isHandoverUsable(input.handoverId))) {
|
||||
return refuse("claim_expired");
|
||||
}
|
||||
|
||||
const email =
|
||||
input.email && input.emailVerified === true
|
||||
? input.email.toLowerCase()
|
||||
: null;
|
||||
const identity = {
|
||||
issuer: input.issuer,
|
||||
sub: input.sub,
|
||||
email,
|
||||
username: input.username,
|
||||
};
|
||||
|
||||
const early = await collision(identity);
|
||||
if (early) return refuse(early);
|
||||
|
||||
let claimed;
|
||||
try {
|
||||
claimed = await claimManagedProfile({
|
||||
link: { handoverId: input.handoverId },
|
||||
username: input.username,
|
||||
email,
|
||||
passwordHash: null,
|
||||
oidc: { issuer: input.issuer, sub: input.sub },
|
||||
ipAddress: input.ipAddress,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof HandoverError) {
|
||||
if (error.code === "taken") {
|
||||
// Lost a race at a unique index: say which one.
|
||||
return refuse((await collision(identity)) ?? "claim_username_taken");
|
||||
}
|
||||
return refuse("claim_expired");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// The new owner starts with onboarding owed, as after the password claim.
|
||||
// The fifth argument stays null: single sign-on is a delegated factor and
|
||||
// must never satisfy a step-up (the OIDC login's rule).
|
||||
await createSession(
|
||||
claimed.profileId,
|
||||
true,
|
||||
input.ipAddress,
|
||||
input.userAgent,
|
||||
null,
|
||||
);
|
||||
void recordSignInDevice({
|
||||
userId: claimed.profileId,
|
||||
ip: input.ipAddress,
|
||||
userAgent: input.userAgent,
|
||||
alertOnNew: false,
|
||||
});
|
||||
await auditLog("auth.claim", {
|
||||
userId: claimed.profileId,
|
||||
actorUserId: null,
|
||||
ipAddress: input.ipAddress,
|
||||
details: { method: "sso" },
|
||||
});
|
||||
|
||||
notifyGuardiansOfHandover("claimed", claimed.displayName, claimed.guardians);
|
||||
|
||||
annotate({
|
||||
action: { name: "profile_claim.complete" },
|
||||
meta: { guardians: claimed.guardians.length, method: "sso" },
|
||||
});
|
||||
return { ok: true, profileId: claimed.profileId };
|
||||
}
|
||||
@@ -444,17 +444,19 @@ export interface HandoverPreview {
|
||||
}
|
||||
|
||||
/**
|
||||
* What a link would hand over, or null for every kind of unusable link.
|
||||
*
|
||||
* Deliberately minimal: the profile's name, the expiry, and who keeps which
|
||||
* access. No health data, no date of birth, no usernames and no grant ids —
|
||||
* the person holding the link has not proved anything yet beyond holding it.
|
||||
* The open link behind a stored row, or null when it can no longer be
|
||||
* claimed: used, withdrawn, expired, a state this code cannot read, a profile
|
||||
* no longer managed, or a creator who lost access. The one definition of
|
||||
* "usable" the preview, the single-sign-on start and the callback share.
|
||||
*/
|
||||
export async function previewHandover(
|
||||
rawToken: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<HandoverPreview | null> {
|
||||
const row = await findHandoverByToken(prisma, rawToken);
|
||||
async function usableHandover(
|
||||
row: Awaited<ReturnType<typeof findHandoverByToken>>,
|
||||
now: Date,
|
||||
): Promise<{
|
||||
row: NonNullable<Awaited<ReturnType<typeof findHandoverByToken>>>;
|
||||
state: HandoverState;
|
||||
displayName: string | null;
|
||||
} | null> {
|
||||
if (!row || !isOpen(row, now)) return null;
|
||||
const state = parseHandoverState(row.proposalsJson);
|
||||
if (!state) return null;
|
||||
@@ -467,6 +469,26 @@ export async function previewHandover(
|
||||
if (!(await isActiveGuardian(prisma, row.profileId, row.createdById, now))) {
|
||||
return null;
|
||||
}
|
||||
return { row, state, displayName: profile.displayName };
|
||||
}
|
||||
|
||||
/**
|
||||
* What a link would hand over, or null for every kind of unusable link.
|
||||
*
|
||||
* Deliberately minimal: the profile's name, the expiry, and who keeps which
|
||||
* access. No health data, no date of birth, no usernames and no grant ids —
|
||||
* the person holding the link has not proved anything yet beyond holding it.
|
||||
*/
|
||||
export async function previewHandover(
|
||||
rawToken: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<HandoverPreview | null> {
|
||||
const usable = await usableHandover(
|
||||
await findHandoverByToken(prisma, rawToken),
|
||||
now,
|
||||
);
|
||||
if (!usable) return null;
|
||||
const { row, state } = usable;
|
||||
|
||||
const guardians = await prisma.accountGrant.findMany({
|
||||
where: { grantorId: row.profileId, ...activeGuardianWhere(now) },
|
||||
@@ -477,7 +499,7 @@ export async function previewHandover(
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
return {
|
||||
displayName: profile.displayName,
|
||||
displayName: usable.displayName,
|
||||
expiresAt: row.expiresAt,
|
||||
guardians: guardians.map((g) => ({
|
||||
displayName: g.grantee.displayName?.trim() || null,
|
||||
@@ -486,6 +508,39 @@ export async function previewHandover(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.43 (#959) — the usable link behind a raw token, or null. The
|
||||
* single-sign-on start seals its id, never the token, into the claim cookie.
|
||||
*/
|
||||
export async function usableHandoverForToken(
|
||||
rawToken: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<{ handoverId: string; profileId: string } | null> {
|
||||
const usable = await usableHandover(
|
||||
await findHandoverByToken(prisma, rawToken),
|
||||
now,
|
||||
);
|
||||
return usable
|
||||
? { handoverId: usable.row.id, profileId: usable.row.profileId }
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.43 (#959) — whether the link with this id can still be claimed. The
|
||||
* single-sign-on legs re-ask on every step, so a link withdrawn or replaced
|
||||
* while the person is at the identity provider is refused before any account
|
||||
* changes. The claim re-checks under the lock; this decides nothing alone.
|
||||
*/
|
||||
export async function isHandoverUsable(
|
||||
handoverId: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<boolean> {
|
||||
const row = await prisma.managedProfileHandover.findUnique({
|
||||
where: { id: handoverId },
|
||||
});
|
||||
return (await usableHandover(row, now)) !== null;
|
||||
}
|
||||
|
||||
export interface ClaimedProfile {
|
||||
profileId: string;
|
||||
username: string;
|
||||
@@ -502,15 +557,33 @@ export interface ClaimedProfile {
|
||||
* strength: none of that work belongs inside the lock.
|
||||
*/
|
||||
export async function claimManagedProfile(input: {
|
||||
rawToken: string;
|
||||
/**
|
||||
* The link: the raw token from the password form, or (v1.43) the id the
|
||||
* single-sign-on start sealed into the claim cookie after checking the
|
||||
* token itself.
|
||||
*/
|
||||
link: { rawToken: string } | { handoverId: string };
|
||||
username: string;
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
/** Null on a single-sign-on claim whose provider vouched for no address. */
|
||||
email: string | null;
|
||||
/** Null on a single-sign-on claim: the account signs in at the provider. */
|
||||
passwordHash: string | null;
|
||||
/**
|
||||
* v1.43 (#959) — the identity provider's subject, bound to the profile in
|
||||
* the same transaction. The `(issuer, sub)` unique index refuses a subject
|
||||
* that another account holds, as `taken`.
|
||||
*/
|
||||
oidc?: { issuer: string; sub: string };
|
||||
ipAddress?: string | null;
|
||||
now?: Date;
|
||||
}): Promise<ClaimedProfile> {
|
||||
const now = input.now ?? new Date();
|
||||
const found = await findHandoverByToken(prisma, input.rawToken);
|
||||
const found =
|
||||
"rawToken" in input.link
|
||||
? await findHandoverByToken(prisma, input.link.rawToken)
|
||||
: await prisma.managedProfileHandover.findUnique({
|
||||
where: { id: input.link.handoverId },
|
||||
});
|
||||
if (!found) throw new HandoverError("invalid");
|
||||
|
||||
try {
|
||||
@@ -551,6 +624,9 @@ export async function claimManagedProfile(input: {
|
||||
username: input.username,
|
||||
email: input.email,
|
||||
passwordHash: input.passwordHash,
|
||||
...(input.oidc
|
||||
? { oidcIssuer: input.oidc.issuer, oidcSub: input.oidc.sub }
|
||||
: {}),
|
||||
disclaimerAcknowledgedAt: null,
|
||||
disclaimerAcknowledgedVersion: null,
|
||||
onboardingCompletedAt: null,
|
||||
@@ -636,6 +712,7 @@ export async function claimManagedProfile(input: {
|
||||
})),
|
||||
withdrawnInvitations,
|
||||
consentsWithdrawn: consent.revoked.length,
|
||||
method: input.oidc ? "sso" : "password",
|
||||
},
|
||||
client: tx,
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -736,7 +736,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The instance allows single sign-on only (`meta.errorCode: profile_claim.oidc_only_unsupported`).",
|
||||
"Neither a password nor single sign-on could redeem a link on this instance (`meta.errorCode: profile_claim.oidc_only_unsupported`). Since v1.43 a single-sign-on-only instance hands over through the identity provider, so a working configuration never answers this.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"404": {
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
} from "@/lib/validations/step-up";
|
||||
import {
|
||||
dataEnvelope,
|
||||
notImplementedResponse,
|
||||
profileEmailRateLimitResponse,
|
||||
stdResponses,
|
||||
errorEnvelope,
|
||||
@@ -768,7 +767,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Auth"],
|
||||
summary: "Claim a managed profile through single sign-on (v1.43)",
|
||||
description:
|
||||
"Web only. Checks the link and the username, sets a sealed `hl_claim` cookie (10 minutes, `httpOnly`) and answers where to continue: `/api/auth/oidc/login?intent=claim`. The OIDC callback binds the identity provider's subject to the profile and signs the new owner in; a subject already bound to an account, or a verified email another account holds, is refused there. Works on a single-sign-on-only instance. 404 `profile_claim.invalid` (no usable link) or `profile_claim.sso_unavailable` (no identity provider configured); 409 `profile_claim.taken` (username) or `auth.already_authenticated`; rate-limited per source address like the password claim.",
|
||||
"Web only. Checks the link and the username, sets a sealed `hl_claim` cookie (10 minutes, `httpOnly`) and answers where to continue: `/api/auth/oidc/login?intent=claim`. The OIDC callback binds the identity provider's subject to the profile and signs the new owner in; a subject already bound to an account, or a verified email another account holds, is refused there. Works on a single-sign-on-only instance. When the callback refuses, the browser lands on `/auth/claim?error=<reason>` with reason `claim_expired` (the link was used, withdrawn, replaced or expired in the meantime, or the cookie is missing, expired or belongs to another start), `claim_sso_subject_bound`, `claim_email_taken`, `claim_username_taken` (taken during the round trip), `claim_signed_in`, or one of the sign-in reasons (`oidc_denied`, `oidc_state`, `oidc_failed`, `oidc_rate_limited`, `oidc_disabled`); on success it lands on `/onboarding/handover`, where the new owner decides each guardian's access. The cookie is cleared either way. 404 `profile_claim.invalid` (no usable link) or `profile_claim.sso_unavailable` (no identity provider configured); 409 `profile_claim.taken` (username) or `auth.already_authenticated`; rate-limited per source address like the password claim.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: claimSsoRequest } },
|
||||
@@ -795,7 +794,6 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -807,7 +805,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Auth"],
|
||||
summary: "Preview a managed-profile handover link",
|
||||
description:
|
||||
"What a handover link would hand over: the profile's display name, the link's expiry, and each guardian's proposal. Anonymous; the token travels in the body. An unknown, expired, used or withdrawn token, and a link whose creator lost access, all answer the same 404. Rate-limited per source address.",
|
||||
"What a handover link would hand over: the profile's display name, the link's expiry, and each guardian's proposal. Anonymous; the token travels in the body. An unknown, expired, used or withdrawn token, and a link whose creator lost access, all answer the same 404. Rate-limited per source address. Since v1.43 answered on a single-sign-on-only instance too, with `methods` naming the ways in.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: claimPreviewRequest } },
|
||||
@@ -821,11 +819,6 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The instance allows single sign-on only (`meta.errorCode: profile_claim.oidc_only_unsupported`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"No usable link behind this token (`meta.errorCode: profile_claim.invalid`). Every failure class answers identically.",
|
||||
@@ -2338,6 +2331,12 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
.describe(
|
||||
"Browser leg only: where to land after login. Sanitised to an in-app path; ignored entirely on the native leg.",
|
||||
),
|
||||
intent: z
|
||||
.literal("claim")
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.43 — `claim` continues a managed-profile claim started at POST /api/auth/claim/sso; the link and username ride the sealed `hl_claim` cookie that call set, never this URL. Without a valid cookie for a still-usable link the start redirects to `/auth/claim?error=claim_expired`. Browser leg only: with `client=native` it fails with `oidc_invalid_request`. `next` is ignored.",
|
||||
),
|
||||
client_proto: z
|
||||
.enum(["http", "https"])
|
||||
.optional()
|
||||
@@ -2356,7 +2355,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
responses: {
|
||||
"302": {
|
||||
description:
|
||||
"Always a redirect. To the provider on success. On failure to `/auth/login?error=<reason>` (browser) or `healthlog://oidc-callback?error=<reason>` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`, and on the browser leg `insecure_transport` (see `client_proto`). The provider then returns to the callback (not listed here, because the identity provider drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with `code=<hlh_…>`, with `mfa_ticket=<ticket>&methods=<list>`, or with `error=<reason>`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `oidc_failed`. `oidc_link_required` means the provider's e-mail matches an existing local account that is not linked yet; the owner links it by signing in once with that account's own password or passkey in a browser. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic sign-in failure.",
|
||||
"Always a redirect. To the provider on success. On failure to `/auth/login?error=<reason>` (browser; `/auth/claim?error=<reason>` with `intent=claim`, where `claim_expired` is one more reason) or `healthlog://oidc-callback?error=<reason>` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`, and on the browser leg `insecure_transport` (see `client_proto`). The provider then returns to the callback (not listed here, because the identity provider drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with `code=<hlh_…>`, with `mfa_ticket=<ticket>&methods=<list>`, or with `error=<reason>`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `oidc_failed`. `oidc_link_required` means the provider's e-mail matches an existing local account that is not linked yet; the owner links it by signing in once with that account's own password or passkey in a browser. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic sign-in failure.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user