mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
fix(auth): count a passkey as the second factor on the managed-profile acts
An account whose only strong credential is a primary passkey was refused auth.stepup.mfa_not_enrolled on every action behind requireFreshMfa, so it could not create, edit, hand over or delete a managed profile or manage its guardians, although a passkey sign-in or re-proof already stamps the session the gate reads. requireFreshMfa now defaults to the any-possession proof source: a passkey makes the gate reachable, and a passkey re-proof through the dialog clears it. The refusal offers passkey in meta.methods, never the password. hasSecondFactorEnrolled keeps its narrow meaning on purpose. It decides whether the conditional gates apply at all (account deletion, the data reset, the password change, the export, offering manage access, the recent-proof branches and the native Bearer rules), so none of those change, and password sign-in still challenges only TOTP and security keys. The MFA-management routes keep the second-factor source. The decision and its limits are written beside FreshFactorProofSource. Copy, the OpenAPI description and the sharing and key-rotation docs say "second factor or passkey" where this gate applies. Refs #959
This commit is contained in:
1 parent
d9b6061d28
commit
792fc89a93
17 files changed
+381
-63
No files matched your search
@@ -12,6 +12,13 @@
|
||||
the second factor needed confirming. They now open the confirmation dialog
|
||||
and retry once confirmed, also after a single sign-on login (Refs #959).
|
||||
The refusal names the methods the account has in `meta.methods`.
|
||||
- **A passkey counts as a second factor for managed profiles.** An account
|
||||
whose only strong sign-in is a passkey was told to set up a second factor
|
||||
before it could create, edit, hand over or delete a managed profile or
|
||||
manage its guardians. A passkey sign-in, or confirming the passkey in the
|
||||
dialog, is now enough; the admin key rotation follows the same rule.
|
||||
Password sign-in, the native app and the actions that ask for a fresh
|
||||
factor only from accounts with TOTP or a security key are unchanged.
|
||||
- An account that signs in only through single sign-on is no longer offered
|
||||
a password field it cannot fill when it sets up a second factor; it is
|
||||
asked to sign in again.
|
||||
|
||||
@@ -21687,9 +21687,10 @@ paths:
|
||||
"401":
|
||||
description: "No fresh second-factor proof (`meta.errorCode: auth.stepup.required`, with `meta.methods` naming the
|
||||
proofs that clear it at POST /api/auth/reproof), or none enrolled at all (`meta.errorCode:
|
||||
auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account without a second factor cannot
|
||||
create a managed profile until it enrols one. The ordinary unauthenticated 401 arrives here too, including
|
||||
for a Bearer caller, whose transport this gate does not read."
|
||||
auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account with neither a second factor nor
|
||||
a passkey cannot create a managed profile until it enrols one. A passkey counts: an account whose only
|
||||
strong credential is a passkey is offered `passkey` in `meta.methods`. The ordinary unauthenticated 401
|
||||
arrives here too, including for a Bearer caller, whose transport this gate does not read."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -34618,8 +34619,9 @@ components:
|
||||
Guardian reading it: they decide how the profile's own reminders are worded and when its day starts. Strict — an
|
||||
unexpected field is a 422 rather than an ignored key. Cookie transport only and step-up gated
|
||||
(`requireFreshMfa`, unconditional): a Bearer caller cannot mint a credential-less person and a permanent
|
||||
management relationship, and an account with no second factor enrolled is refused rather than waved through.
|
||||
Rate-limited to ten an hour per caller."
|
||||
management relationship, and an account with no second factor enrolled is refused rather than waved through. A
|
||||
primary passkey counts as that factor here: a passkey sign-in or a passkey re-proof at POST /api/auth/reproof
|
||||
stamps the session. Rate-limited to ten an hour per caller."
|
||||
UpdateManagedProfileRequest:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -43,12 +43,12 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
|
||||
retained.
|
||||
3. **Re-encrypt the stored data under the new key.** Open **Admin →
|
||||
Encryption** and press **Rotate now**. The button needs an admin account
|
||||
with a second factor, confirmed recently; it queues one background run
|
||||
with a second factor or a passkey, confirmed recently; it queues one background run
|
||||
(`POST /api/admin/encryption/rotate`) on the app's own worker, so it
|
||||
works on the published image with nothing else installed. The same page
|
||||
shows the per-column counts and the result of the last run.
|
||||
|
||||
Without a second factor on any admin account, run the rotation script
|
||||
Without a second factor or a passkey on any admin account, run the rotation script
|
||||
instead. It imports the application code and the generated database
|
||||
client, so it runs from a source checkout of the same release, not
|
||||
inside the production container (the image has no package manager and
|
||||
|
||||
@@ -189,9 +189,11 @@ no password, no passkey and no e-mail address, and it is never presented
|
||||
as something to log in as.
|
||||
|
||||
- It is created from **Settings → Shared access**, under a fresh second
|
||||
factor. An account with no second factor enrolled is refused rather than
|
||||
waved through, because the act creates a permanent management
|
||||
relationship over a person who cannot object to it.
|
||||
factor. A passkey counts as one: a guardian who signs in with a passkey,
|
||||
or confirms it in the dialog the app opens, gets through. An account with
|
||||
neither a second factor nor a passkey is refused rather than waved
|
||||
through, because the act creates a permanent management relationship
|
||||
over a person who cannot object to it.
|
||||
- Creation takes a display name, optionally a real date of birth, and the
|
||||
language and timezone the record's own days and reminders are measured
|
||||
in. A year on its own is never turned into the first of January. The
|
||||
@@ -356,10 +358,11 @@ Four operational notes:
|
||||
and no address to look it up by.
|
||||
- **The second-factor requirement is not the same on both features, and
|
||||
the difference is deliberate.** Creating a managed profile, deleting
|
||||
one and inviting a second guardian require a second factor
|
||||
unconditionally: an account with none enrolled is refused and told to
|
||||
set one up, because those acts create or end a permanent relationship
|
||||
over somebody who cannot object. Offering manage access on an ordinary
|
||||
invitation asks for a fresh factor only from an account that has one, so
|
||||
one, handing it over and inviting a second guardian require a second
|
||||
factor unconditionally, and a passkey counts as one there: an account
|
||||
with neither is refused and told to set one up, because those acts
|
||||
create or end a permanent relationship over somebody who cannot object.
|
||||
Offering manage access on an ordinary invitation asks for a fresh factor
|
||||
only from an account with TOTP or a security key, so
|
||||
an instance where nobody has enrolled a factor can still use every level
|
||||
of ordinary sharing. All of them are browser-only either way.
|
||||
+4
-4
@@ -5160,7 +5160,7 @@
|
||||
"changeInSettings": "Unter Einstellungen, Konto ändern",
|
||||
"cta": "Bestätigen und weiter",
|
||||
"managedTitle": "Die Person, um die du dich kümmerst",
|
||||
"managedBody": "Ihre Akte hat keinen eigenen Login; du betreust sie aus deinem Konto. Zum Anlegen ist ein zweiter Faktor auf deinem Konto nötig.",
|
||||
"managedBody": "Ihre Akte hat keinen eigenen Login; du betreust sie aus deinem Konto. Zum Anlegen ist ein zweiter Faktor oder ein Passkey auf deinem Konto nötig.",
|
||||
"managedCreate": "Profil anlegen und abschließen",
|
||||
"managedLater": "Du kannst es auch später unter Einstellungen, Geteilter Zugriff anlegen.",
|
||||
"finishWithoutProfile": "Ohne Profil abschließen"
|
||||
@@ -12261,7 +12261,7 @@
|
||||
"errorFailed": "Die Einladung konnte nicht gesendet werden. Bitte noch einmal versuchen.",
|
||||
"errorScope": "So lassen sich diese Bereiche nicht teilen. Wähle mindestens einen Bereich aus oder teile die gesamte Akte.",
|
||||
"errorManageBrowserOnly": "Verwaltenden Zugriff kannst du nur im Browser vergeben.",
|
||||
"errorStepUp": "Bestätige deinen zweiten Faktor und versuche es erneut.",
|
||||
"errorStepUp": "Bestätige deinen zweiten Faktor oder Passkey und versuche es erneut.",
|
||||
"errorOffline": "Das hat den Server nicht erreicht. Deine Einladung steht noch hier. Prüfe deine Verbindung und versuche es erneut."
|
||||
},
|
||||
"given": {
|
||||
@@ -12338,7 +12338,7 @@
|
||||
"create": "Profil anlegen",
|
||||
"creating": "Wird angelegt…",
|
||||
"created": "{name} ist eingerichtet. Über das Kontomenü kannst du in diese Akte wechseln.",
|
||||
"errorStepUp": "Bestätige deinen zweiten Faktor und versuche es erneut.",
|
||||
"errorStepUp": "Bestätige deinen zweiten Faktor oder Passkey und versuche es erneut.",
|
||||
"errorOffline": "Das hat den Server nicht erreicht. Es hat sich nichts geändert. Prüfe deine Verbindung und versuche es erneut.",
|
||||
"errorInvalid": "Der Server hat diese Angaben abgelehnt. Prüfe Name, Datum und Zeitzone.",
|
||||
"errorFailed": "Das Profil konnte nicht angelegt werden.",
|
||||
@@ -12377,7 +12377,7 @@
|
||||
"guardianErrorNoAccount": "Auf dieser Instanz gibt es kein Konto mit diesem Namen oder dieser E-Mail-Adresse.",
|
||||
"guardianErrorRateLimit": "Zu viele Einladungen in der letzten Stunde. Warte etwas und versuche es erneut.",
|
||||
"guardianErrorFailed": "Die Einladung konnte nicht gesendet werden.",
|
||||
"errorMfaRequired": "Dafür ist ein zweiter Faktor nötig, und dein Konto hat noch keinen. Richte unter Einstellungen → Sicherheit einen ein und versuche es dann erneut.",
|
||||
"errorMfaRequired": "Dafür ist ein zweiter Faktor oder ein Passkey nötig, und dein Konto hat noch keins von beiden. Richte unter Einstellungen → Sicherheit eins ein und versuche es dann erneut.",
|
||||
"rosterLoadError": "Es ließ sich nicht laden, wer sich um diese Akte kümmert.",
|
||||
"errorRateLimit": "Zu viele Profile in der letzten Stunde angelegt. Warte etwas und versuche es erneut.",
|
||||
"handover": {
|
||||
|
||||
+4
-4
@@ -5160,7 +5160,7 @@
|
||||
"changeInSettings": "Change these under Settings, Account",
|
||||
"cta": "Confirm and continue",
|
||||
"managedTitle": "The person you look after",
|
||||
"managedBody": "Their record has no login of its own; you look after it from your account. A second factor on your account is required to create it.",
|
||||
"managedBody": "Their record has no login of its own; you look after it from your account. A second factor or a passkey on your account is required to create it.",
|
||||
"managedCreate": "Create the profile and finish",
|
||||
"managedLater": "You can also create it later under Settings, Shared access.",
|
||||
"finishWithoutProfile": "Finish without the profile"
|
||||
@@ -12261,7 +12261,7 @@
|
||||
"errorFailed": "The invitation could not be sent. Please try again.",
|
||||
"errorScope": "Those sections cannot be shared like that. Pick at least one section, or share the entire record.",
|
||||
"errorManageBrowserOnly": "Manage access can only be offered in a browser.",
|
||||
"errorStepUp": "Confirm your second factor, then try again.",
|
||||
"errorStepUp": "Confirm your second factor or passkey, then try again.",
|
||||
"errorOffline": "That did not reach the server. Your invitation is still here. Check your connection and try again."
|
||||
},
|
||||
"given": {
|
||||
@@ -12338,7 +12338,7 @@
|
||||
"create": "Create profile",
|
||||
"creating": "Creating…",
|
||||
"created": "{name} is set up. You can switch into that record from the account menu.",
|
||||
"errorStepUp": "Confirm your second factor, then try again.",
|
||||
"errorStepUp": "Confirm your second factor or passkey, then try again.",
|
||||
"errorOffline": "That did not reach the server. Nothing has changed. Check your connection and try again.",
|
||||
"errorInvalid": "The server refused those details. Check the name, the date and the timezone.",
|
||||
"errorFailed": "The profile could not be created.",
|
||||
@@ -12377,7 +12377,7 @@
|
||||
"guardianErrorNoAccount": "No account on this instance carries that name or e-mail.",
|
||||
"guardianErrorRateLimit": "Too many invitations in the last hour. Wait a while and try again.",
|
||||
"guardianErrorFailed": "The invitation could not be sent.",
|
||||
"errorMfaRequired": "This needs a second factor, and your account does not have one yet. Set one up under Settings → Security, then try again.",
|
||||
"errorMfaRequired": "This needs a second factor or a passkey, and your account has neither yet. Set one up under Settings → Security, then try again.",
|
||||
"rosterLoadError": "Could not load who looks after this record.",
|
||||
"errorRateLimit": "Too many profiles created in the last hour. Wait a while and try again.",
|
||||
"handover": {
|
||||
|
||||
+4
-4
@@ -5160,7 +5160,7 @@
|
||||
"changeInSettings": "Cámbialo en Ajustes, Cuenta",
|
||||
"cta": "Confirmar y continuar",
|
||||
"managedTitle": "La persona a tu cargo",
|
||||
"managedBody": "Su registro no tiene inicio de sesión propio; lo gestionas desde tu cuenta. Para crearlo hace falta un segundo factor en tu cuenta.",
|
||||
"managedBody": "Su registro no tiene inicio de sesión propio; lo gestionas desde tu cuenta. Para crearlo hace falta un segundo factor o una llave de acceso en tu cuenta.",
|
||||
"managedCreate": "Crear el perfil y terminar",
|
||||
"managedLater": "También puedes crearlo después en Ajustes, Acceso compartido.",
|
||||
"finishWithoutProfile": "Terminar sin el perfil"
|
||||
@@ -12261,7 +12261,7 @@
|
||||
"errorFailed": "No se pudo enviar la invitación. Inténtalo de nuevo.",
|
||||
"errorScope": "Esas secciones no se pueden compartir así. Elige al menos una sección o comparte todo el historial.",
|
||||
"errorManageBrowserOnly": "El acceso de gestión solo se puede ofrecer desde un navegador.",
|
||||
"errorStepUp": "Confirma tu segundo factor y vuelve a intentarlo.",
|
||||
"errorStepUp": "Confirma tu segundo factor o tu llave de acceso y vuelve a intentarlo.",
|
||||
"errorOffline": "Esto no llegó al servidor. Tu invitación sigue aquí: comprueba tu conexión e inténtalo de nuevo."
|
||||
},
|
||||
"given": {
|
||||
@@ -12338,7 +12338,7 @@
|
||||
"create": "Crear perfil",
|
||||
"creating": "Creando…",
|
||||
"created": "{name} ya está listo. Puedes entrar en ese historial desde el menú de la cuenta.",
|
||||
"errorStepUp": "Confirma tu segundo factor y vuelve a intentarlo.",
|
||||
"errorStepUp": "Confirma tu segundo factor o tu llave de acceso y vuelve a intentarlo.",
|
||||
"errorOffline": "Esto no llegó al servidor. No ha cambiado nada. Revisa tu conexión y vuelve a intentarlo.",
|
||||
"errorInvalid": "El servidor rechazó esos datos. Revisa el nombre, la fecha y la zona horaria.",
|
||||
"errorFailed": "No se pudo crear el perfil.",
|
||||
@@ -12377,7 +12377,7 @@
|
||||
"guardianErrorNoAccount": "En esta instancia no hay ninguna cuenta con ese nombre o correo.",
|
||||
"guardianErrorRateLimit": "Demasiadas invitaciones en la última hora. Espera un poco y vuelve a intentarlo.",
|
||||
"guardianErrorFailed": "No se pudo enviar la invitación.",
|
||||
"errorMfaRequired": "Esto necesita un segundo factor y tu cuenta aún no tiene ninguno. Configura uno en Ajustes → Seguridad y vuelve a intentarlo.",
|
||||
"errorMfaRequired": "Esto necesita un segundo factor o una llave de acceso, y tu cuenta aún no tiene ninguno. Configura uno en Ajustes → Seguridad y vuelve a intentarlo.",
|
||||
"rosterLoadError": "No se pudo cargar quién se ocupa de este historial.",
|
||||
"errorRateLimit": "Demasiados perfiles creados en la última hora. Espera un poco y vuelve a intentarlo.",
|
||||
"handover": {
|
||||
|
||||
+4
-4
@@ -5160,7 +5160,7 @@
|
||||
"changeInSettings": "À modifier dans Réglages, Compte",
|
||||
"cta": "Confirmer et continuer",
|
||||
"managedTitle": "La personne dont vous vous occupez",
|
||||
"managedBody": "Son dossier n'a pas d'identifiant propre ; vous le gérez depuis votre compte. Un second facteur sur votre compte est nécessaire pour le créer.",
|
||||
"managedBody": "Son dossier n'a pas d'identifiant propre ; vous le gérez depuis votre compte. Un second facteur ou une clé d'accès sur votre compte est nécessaire pour le créer.",
|
||||
"managedCreate": "Créer le profil et terminer",
|
||||
"managedLater": "Vous pouvez aussi le créer plus tard dans Réglages, Accès partagé.",
|
||||
"finishWithoutProfile": "Terminer sans le profil"
|
||||
@@ -12261,7 +12261,7 @@
|
||||
"errorFailed": "L'invitation n'a pas pu être envoyée. Veuillez réessayer.",
|
||||
"errorScope": "Ces rubriques ne peuvent pas être partagées ainsi. Choisissez au moins une rubrique, ou partagez le dossier complet.",
|
||||
"errorManageBrowserOnly": "Un accès de gestion ne peut être proposé que depuis un navigateur.",
|
||||
"errorStepUp": "Confirmez votre deuxième facteur, puis réessayez.",
|
||||
"errorStepUp": "Confirmez votre deuxième facteur ou votre clé d'accès, puis réessayez.",
|
||||
"errorOffline": "Cela n'a pas atteint le serveur. Votre invitation est toujours là : vérifiez votre connexion et réessayez."
|
||||
},
|
||||
"given": {
|
||||
@@ -12338,7 +12338,7 @@
|
||||
"create": "Créer le profil",
|
||||
"creating": "Création…",
|
||||
"created": "{name} est en place. Vous pouvez entrer dans ce dossier depuis le menu du compte.",
|
||||
"errorStepUp": "Confirmez votre deuxième facteur, puis réessayez.",
|
||||
"errorStepUp": "Confirmez votre deuxième facteur ou votre clé d'accès, puis réessayez.",
|
||||
"errorOffline": "Cela n'a pas atteint le serveur. Rien n'a changé. Vérifiez votre connexion et réessayez.",
|
||||
"errorInvalid": "Le serveur a refusé ces informations. Vérifiez le nom, la date et le fuseau horaire.",
|
||||
"errorFailed": "Le profil n'a pas pu être créé.",
|
||||
@@ -12377,7 +12377,7 @@
|
||||
"guardianErrorNoAccount": "Aucun compte de cette instance ne porte ce nom ou cette adresse e-mail.",
|
||||
"guardianErrorRateLimit": "Trop d'invitations dans la dernière heure. Patientez un peu et réessayez.",
|
||||
"guardianErrorFailed": "L'invitation n'a pas pu être envoyée.",
|
||||
"errorMfaRequired": "Cela demande un deuxième facteur, et votre compte n'en a pas encore. Configurez-en un dans Réglages → Sécurité, puis réessayez.",
|
||||
"errorMfaRequired": "Cela demande un deuxième facteur ou une clé d'accès, et votre compte n'a encore ni l'un ni l'autre. Configurez-en un dans Réglages → Sécurité, puis réessayez.",
|
||||
"rosterLoadError": "Impossible de charger qui s'occupe de ce dossier.",
|
||||
"errorRateLimit": "Trop de profils créés dans la dernière heure. Patientez un peu et réessayez.",
|
||||
"handover": {
|
||||
|
||||
+4
-4
@@ -5160,7 +5160,7 @@
|
||||
"changeInSettings": "Modificali in Impostazioni, Account",
|
||||
"cta": "Conferma e continua",
|
||||
"managedTitle": "La persona di cui ti prendi cura",
|
||||
"managedBody": "La sua cartella non ha un accesso proprio; la gestisci dal tuo account. Per crearla serve un secondo fattore sul tuo account.",
|
||||
"managedBody": "La sua cartella non ha un accesso proprio; la gestisci dal tuo account. Per crearla serve un secondo fattore o una passkey sul tuo account.",
|
||||
"managedCreate": "Crea il profilo e concludi",
|
||||
"managedLater": "Puoi crearlo anche in seguito in Impostazioni, Accesso condiviso.",
|
||||
"finishWithoutProfile": "Concludi senza il profilo"
|
||||
@@ -12261,7 +12261,7 @@
|
||||
"errorFailed": "Non è stato possibile inviare l'invito. Riprova.",
|
||||
"errorScope": "Queste sezioni non si possono condividere così. Scegli almeno una sezione oppure condividi l'intera cartella.",
|
||||
"errorManageBrowserOnly": "L'accesso di gestione si può offrire solo da un browser.",
|
||||
"errorStepUp": "Conferma il tuo secondo fattore e riprova.",
|
||||
"errorStepUp": "Conferma il tuo secondo fattore o la tua passkey e riprova.",
|
||||
"errorOffline": "La richiesta non ha raggiunto il server. Il tuo invito è ancora qui: controlla la connessione e riprova."
|
||||
},
|
||||
"given": {
|
||||
@@ -12338,7 +12338,7 @@
|
||||
"create": "Crea profilo",
|
||||
"creating": "Creazione in corso…",
|
||||
"created": "{name} è pronto. Puoi entrare in quella cartella dal menu dell'account.",
|
||||
"errorStepUp": "Conferma il tuo secondo fattore e riprova.",
|
||||
"errorStepUp": "Conferma il tuo secondo fattore o la tua passkey e riprova.",
|
||||
"errorOffline": "Non è arrivato al server. Non è cambiato nulla. Controlla la connessione e riprova.",
|
||||
"errorInvalid": "Il server ha rifiutato questi dati. Controlla nome, data e fuso orario.",
|
||||
"errorFailed": "Non è stato possibile creare il profilo.",
|
||||
@@ -12377,7 +12377,7 @@
|
||||
"guardianErrorNoAccount": "Su questa istanza non esiste un account con quel nome o quella e-mail.",
|
||||
"guardianErrorRateLimit": "Troppi inviti nell'ultima ora. Aspetta un po' e riprova.",
|
||||
"guardianErrorFailed": "Non è stato possibile inviare l'invito.",
|
||||
"errorMfaRequired": "Serve un secondo fattore e il tuo account non ne ha ancora uno. Impostane uno in Impostazioni → Sicurezza e riprova.",
|
||||
"errorMfaRequired": "Serve un secondo fattore o una passkey e il tuo account non ha ancora né l'uno né l'altra. Impostane uno in Impostazioni → Sicurezza e riprova.",
|
||||
"rosterLoadError": "Non è stato possibile caricare chi si occupa di questa cartella.",
|
||||
"errorRateLimit": "Troppi profili creati nell'ultima ora. Aspetta un po' e riprova.",
|
||||
"handover": {
|
||||
|
||||
+4
-4
@@ -5160,7 +5160,7 @@
|
||||
"changeInSettings": "설정, 계정에서 변경",
|
||||
"cta": "확인하고 계속",
|
||||
"managedTitle": "내가 돌보는 사람",
|
||||
"managedBody": "이 기록에는 별도의 로그인이 없으며 내 계정에서 관리합니다. 만들려면 내 계정에 2차 인증이 필요합니다.",
|
||||
"managedBody": "이 기록에는 별도의 로그인이 없으며 내 계정에서 관리합니다. 만들려면 내 계정에 2차 인증 또는 패스키가 필요합니다.",
|
||||
"managedCreate": "프로필 만들고 마치기",
|
||||
"managedLater": "나중에 설정, 공유 접근에서 만들 수도 있습니다.",
|
||||
"finishWithoutProfile": "프로필 없이 마치기"
|
||||
@@ -12261,7 +12261,7 @@
|
||||
"errorFailed": "초대를 보내지 못했어요. 다시 시도해 주세요.",
|
||||
"errorScope": "그 섹션들은 그렇게 공유할 수 없어요. 섹션을 하나 이상 고르거나 기록 전체를 공유하세요.",
|
||||
"errorManageBrowserOnly": "관리 권한은 브라우저에서만 줄 수 있어요.",
|
||||
"errorStepUp": "2단계 인증을 확인한 뒤 다시 시도해 주세요.",
|
||||
"errorStepUp": "2단계 인증 또는 패스키를 확인한 뒤 다시 시도해 주세요.",
|
||||
"errorOffline": "서버에 닿지 못했어요. 초대 내용은 그대로 있으니 연결을 확인하고 다시 시도해 주세요."
|
||||
},
|
||||
"given": {
|
||||
@@ -12338,7 +12338,7 @@
|
||||
"create": "프로필 만들기",
|
||||
"creating": "만드는 중…",
|
||||
"created": "{name} 님 프로필을 만들었어요. 계정 메뉴에서 그 기록으로 전환할 수 있어요.",
|
||||
"errorStepUp": "2단계 인증을 확인한 뒤 다시 시도해 주세요.",
|
||||
"errorStepUp": "2단계 인증 또는 패스키를 확인한 뒤 다시 시도해 주세요.",
|
||||
"errorOffline": "서버에 닿지 못했어요. 바뀐 것은 없으니 연결을 확인하고 다시 시도해 주세요.",
|
||||
"errorInvalid": "서버가 그 내용을 거절했어요. 이름과 날짜, 시간대를 확인해 주세요.",
|
||||
"errorFailed": "프로필을 만들지 못했어요.",
|
||||
@@ -12377,7 +12377,7 @@
|
||||
"guardianErrorNoAccount": "이 인스턴스에 그 이름이나 이메일을 쓰는 계정이 없어요.",
|
||||
"guardianErrorRateLimit": "최근 한 시간 동안 초대가 너무 많았어요. 잠시 기다렸다 다시 시도해 주세요.",
|
||||
"guardianErrorFailed": "초대를 보내지 못했어요.",
|
||||
"errorMfaRequired": "여기에는 2단계 인증이 필요한데 계정에 아직 없어요. 설정 → 보안에서 등록한 뒤 다시 시도해 주세요.",
|
||||
"errorMfaRequired": "여기에는 2단계 인증 또는 패스키가 필요한데 계정에 아직 둘 다 없어요. 설정 → 보안에서 하나를 등록한 뒤 다시 시도해 주세요.",
|
||||
"rosterLoadError": "이 기록을 돌보는 사람을 불러오지 못했어요.",
|
||||
"errorRateLimit": "최근 한 시간 동안 만든 프로필이 너무 많아요. 잠시 기다렸다 다시 시도해 주세요.",
|
||||
"handover": {
|
||||
|
||||
+4
-4
@@ -5160,7 +5160,7 @@
|
||||
"changeInSettings": "Zmień w Ustawieniach, Konto",
|
||||
"cta": "Potwierdź i kontynuuj",
|
||||
"managedTitle": "Osoba, którą się opiekujesz",
|
||||
"managedBody": "Jej kartoteka nie ma własnego logowania; zarządzasz nią ze swojego konta. Do jej utworzenia potrzebny jest drugi składnik na Twoim koncie.",
|
||||
"managedBody": "Jej kartoteka nie ma własnego logowania; zarządzasz nią ze swojego konta. Do jej utworzenia potrzebny jest drugi składnik albo klucz dostępu na Twoim koncie.",
|
||||
"managedCreate": "Utwórz profil i zakończ",
|
||||
"managedLater": "Możesz też utworzyć go później w Ustawieniach, Dostęp współdzielony.",
|
||||
"finishWithoutProfile": "Zakończ bez profilu"
|
||||
@@ -12261,7 +12261,7 @@
|
||||
"errorFailed": "Nie udało się wysłać zaproszenia. Spróbuj ponownie.",
|
||||
"errorScope": "Tych sekcji nie da się udostępnić w ten sposób. Wybierz co najmniej jedną sekcję albo udostępnij całą kartotekę.",
|
||||
"errorManageBrowserOnly": "Dostęp zarządzający można przyznać tylko w przeglądarce.",
|
||||
"errorStepUp": "Potwierdź drugi składnik logowania i spróbuj ponownie.",
|
||||
"errorStepUp": "Potwierdź drugi składnik logowania albo klucz dostępu i spróbuj ponownie.",
|
||||
"errorOffline": "To nie dotarło do serwera. Twoje zaproszenie nadal tu jest. Sprawdź połączenie i spróbuj ponownie."
|
||||
},
|
||||
"given": {
|
||||
@@ -12338,7 +12338,7 @@
|
||||
"create": "Utwórz profil",
|
||||
"creating": "Tworzenie…",
|
||||
"created": "{name} jest gotowy. Do tej karty przejdziesz z menu konta.",
|
||||
"errorStepUp": "Potwierdź drugi składnik logowania i spróbuj ponownie.",
|
||||
"errorStepUp": "Potwierdź drugi składnik logowania albo klucz dostępu i spróbuj ponownie.",
|
||||
"errorOffline": "To nie dotarło do serwera. Nic się nie zmieniło. Sprawdź połączenie i spróbuj ponownie.",
|
||||
"errorInvalid": "Serwer odrzucił te dane. Sprawdź nazwę, datę i strefę czasową.",
|
||||
"errorFailed": "Nie udało się utworzyć profilu.",
|
||||
@@ -12377,7 +12377,7 @@
|
||||
"guardianErrorNoAccount": "W tej instancji nie ma konta o takiej nazwie ani takim adresie e-mail.",
|
||||
"guardianErrorRateLimit": "Zbyt wiele zaproszeń w ostatniej godzinie. Odczekaj chwilę i spróbuj ponownie.",
|
||||
"guardianErrorFailed": "Nie udało się wysłać zaproszenia.",
|
||||
"errorMfaRequired": "To wymaga drugiego składnika logowania, a Twoje konto jeszcze go nie ma. Skonfiguruj go w Ustawieniach → Bezpieczeństwo i spróbuj ponownie.",
|
||||
"errorMfaRequired": "To wymaga drugiego składnika logowania albo klucza dostępu, a Twoje konto nie ma jeszcze żadnego z nich. Skonfiguruj jeden w Ustawieniach → Bezpieczeństwo i spróbuj ponownie.",
|
||||
"rosterLoadError": "Nie udało się wczytać, kto opiekuje się tą kartą.",
|
||||
"errorRateLimit": "Zbyt wiele profili utworzonych w ostatniej godzinie. Odczekaj chwilę i spróbuj ponownie.",
|
||||
"handover": {
|
||||
|
||||
@@ -600,7 +600,7 @@ describe("ending a managed profile", () => {
|
||||
"p1",
|
||||
);
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain("Confirm your second factor");
|
||||
expect(html).toContain("Confirm your second factor or passkey");
|
||||
expect(html).not.toContain("The profile could not be deleted");
|
||||
});
|
||||
|
||||
@@ -616,7 +616,7 @@ describe("ending a managed profile", () => {
|
||||
"p1",
|
||||
);
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain("does not have one yet");
|
||||
expect(html).toContain("has neither yet");
|
||||
expect(html).toContain("Security");
|
||||
});
|
||||
|
||||
|
||||
@@ -133,9 +133,10 @@ describe("requireFreshMfa", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a user with neither TOTP nor a security key", async () => {
|
||||
it("rejects a user with neither TOTP nor a security key nor a passkey", async () => {
|
||||
mockSession({ ...MFA_USER, totpConfirmedAt: null });
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.passkey.count).mockResolvedValue(0 as never);
|
||||
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
|
||||
errorCode: "auth.stepup.mfa_not_enrolled",
|
||||
});
|
||||
@@ -163,21 +164,50 @@ describe("requireFreshMfa", () => {
|
||||
* parameter, and it changes nothing else: the freshness read below is the same
|
||||
* read either way.
|
||||
*
|
||||
* A primary passkey is deliberately outside the default set and inside
|
||||
* `"any-possession"`. It DOES stamp `mfaVerifiedAt` on login, so it can clear
|
||||
* the gate — but counting it by default would pull every passkey-holding
|
||||
* Since v1.43.3 a primary passkey counts by default: `requireFreshMfa`
|
||||
* defaults to `"any-possession"`, so a passkey-only account clears the
|
||||
* managed-profile acts with a passkey re-proof. It DOES stamp `mfaVerifiedAt`
|
||||
* on login and on re-proof, so it can clear the gate. What stays narrow is
|
||||
* `hasSecondFactorEnrolled`: widening THAT would pull every passkey-holding
|
||||
* account into `requireFreshMfaIfEnrolled`'s gate on account deletion, the
|
||||
* data reset, the password change, the encrypted export and key rotation,
|
||||
* none of which have ever applied to them. The sibling suite
|
||||
* `require-fresh-mfa-if-enrolled.test.ts` is what catches that if the two
|
||||
* predicates are ever collapsed into one.
|
||||
* data reset, the password change, the encrypted export and key rotation.
|
||||
* The sibling suite `require-fresh-mfa-if-enrolled.test.ts` is what catches
|
||||
* that if the two predicates are ever collapsed into one.
|
||||
*/
|
||||
it("admits a passkey-only account by default, with a fresh stamp", async () => {
|
||||
mockSession({ ...MFA_USER, totpConfirmedAt: null });
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
mfaVerifiedAt: new Date(Date.now() - 60_000),
|
||||
} as never);
|
||||
|
||||
const ctx = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
expect(ctx.user.id).toBe("user-1");
|
||||
});
|
||||
|
||||
it("asks a passkey-only account for its passkey by default when the stamp is stale", async () => {
|
||||
mockSession({ ...MFA_USER, totpConfirmedAt: null });
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
mfaVerifiedAt: null,
|
||||
} as never);
|
||||
|
||||
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
|
||||
errorCode: "auth.stepup.required",
|
||||
extraMeta: { methods: ["passkey"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a passkey-only account when only a second factor counts", async () => {
|
||||
mockSession({ ...MFA_USER, totpConfirmedAt: null });
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
|
||||
|
||||
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
|
||||
await expect(
|
||||
requireFreshMfa(5 * 60, "second-factor"),
|
||||
).rejects.toMatchObject({
|
||||
errorCode: "auth.stepup.mfa_not_enrolled",
|
||||
});
|
||||
});
|
||||
|
||||
+41
-7
@@ -1253,11 +1253,13 @@ export type FreshMfaContext = AuthContext & { mfaVerifiedAt: Date };
|
||||
* the route is protecting.
|
||||
*
|
||||
* `second-factor` — a confirmed TOTP secret or a registered second-factor
|
||||
* security key. The default, and the right question for the routes that MANAGE
|
||||
* a second factor: there is nothing there to protect if nothing is enrolled.
|
||||
* security key. The default of `requireMfaManagementAuth`, and the right
|
||||
* question for the routes that MANAGE a second factor: there is nothing there
|
||||
* to protect if nothing is enrolled.
|
||||
*
|
||||
* `any-possession` — the above, OR a registered PRIMARY passkey. The right
|
||||
* question for a route protecting the passkey itself, because a passkey is a
|
||||
* `any-possession` — the above, OR a registered PRIMARY passkey. The default
|
||||
* of `requireFreshMfa` since v1.43.3, and the right question for a route
|
||||
* protecting the passkey itself, because a passkey is a
|
||||
* possession factor in its own right and this codebase already treats it as
|
||||
* one in three places: `/api/auth/passkey/login-verify` stamps `mfaVerifiedAt`
|
||||
* on a passkey login (v1.23, M-review M1), `passkey` is in
|
||||
@@ -1270,6 +1272,37 @@ export type FreshMfaContext = AuthContext & { mfaVerifiedAt: Date };
|
||||
*/
|
||||
export type FreshFactorProofSource = "second-factor" | "any-possession";
|
||||
|
||||
/*
|
||||
* A PASSKEY COUNTS AS A SECOND FACTOR FOR THE UNCONDITIONAL STEP-UP GATE.
|
||||
*
|
||||
* Maintainer decision, v1.43.3. `requireFreshMfa` defaults to
|
||||
* `any-possession`, so an account whose only strong credential is a passkey
|
||||
* is no longer refused `mfa_not_enrolled` on the routes that call it directly
|
||||
* (creating, editing, deleting and handing over a managed profile, inviting
|
||||
* and removing a guardian, the admin key rotation). It clears the gate the
|
||||
* way every other account does: a passkey sign-in or a passkey re-proof at
|
||||
* `POST /api/auth/reproof` stamps `Session.mfaVerifiedAt`, and the gate reads
|
||||
* that stamp inside the same five minutes.
|
||||
*
|
||||
* The limits, each deliberate:
|
||||
*
|
||||
* - `hasSecondFactorEnrolled` keeps its narrow meaning. It decides whether
|
||||
* the CONDITIONAL gates (`requireFreshMfaIfEnrolled`,
|
||||
* `requireFreshMfaOrElevationIfEnrolled`, `requireRecentProof`'s branches,
|
||||
* the enrollment proofs) apply at all. Widening it would newly gate
|
||||
* account deletion, the data reset, the password change and the export
|
||||
* for every passkey holder, take the password re-proof away from them, and
|
||||
* change what the native app's Bearer requests need. None of that is part
|
||||
* of the decision.
|
||||
* - Password login is untouched. It asks for a second factor from
|
||||
* `totpConfirmedAt` and the security-key count only; a passkey-only
|
||||
* account signing in with its password is not challenged, as before.
|
||||
* - The MFA-management routes (`requireMfaManagementAuth`) still default to
|
||||
* `second-factor`: disabling TOTP or rotating recovery codes on an account
|
||||
* with no second factor has nothing to protect.
|
||||
* - Cookie only, as ever. The Bearer path is not reached from here.
|
||||
*/
|
||||
|
||||
/**
|
||||
* v1.23 — step-up gate. Passes only for a COOKIE session whose
|
||||
* `Session.mfaVerifiedAt` is within `maxAgeSeconds` AND whose user holds a
|
||||
@@ -1285,12 +1318,13 @@ export type FreshFactorProofSource = "second-factor" | "any-possession";
|
||||
* waves gate account deletion, key rotation, and passphrase export on it.
|
||||
*
|
||||
* @param proofSource which credentials count as "can prove a factor". Defaults
|
||||
* to the second-factor set every existing caller means; see
|
||||
* {@link FreshFactorProofSource}.
|
||||
* to `any-possession`, so a primary passkey counts (see the decision note
|
||||
* below {@link FreshFactorProofSource}); `requireMfaManagementAuth` passes
|
||||
* `second-factor` explicitly.
|
||||
*/
|
||||
export async function requireFreshMfa(
|
||||
maxAgeSeconds: number,
|
||||
proofSource: FreshFactorProofSource = "second-factor",
|
||||
proofSource: FreshFactorProofSource = "any-possession",
|
||||
): Promise<FreshMfaContext> {
|
||||
const sessionData = await getSession();
|
||||
if (!sessionData)
|
||||
|
||||
@@ -21,6 +21,13 @@ import { prisma } from "@/lib/db";
|
||||
* a behaviour change to five destructive actions, made in passing, for the
|
||||
* benefit of a sixth. `canProveFreshFactor` in `api-handler.ts` is where the wider question
|
||||
* lives.
|
||||
*
|
||||
* Since v1.43.3 a passkey DOES count as a second factor where the gate is
|
||||
* unconditional: `requireFreshMfa` defaults to `any-possession`, so a
|
||||
* passkey-only account clears the managed-profile acts with a passkey
|
||||
* re-proof instead of being told to enrol something. That decision was made
|
||||
* there and not here, on purpose; the note beside `FreshFactorProofSource`
|
||||
* lists what it does and does not change.
|
||||
*/
|
||||
export async function hasSecondFactorEnrolled(user: {
|
||||
id: string;
|
||||
|
||||
@@ -75,7 +75,7 @@ const accountGrantInvite = inviteGrantSchema.meta({
|
||||
const createManagedProfileRequest = createManagedProfileSchema.meta({
|
||||
id: "CreateManagedProfileRequest",
|
||||
description:
|
||||
"Create a health record for somebody who has no login of their own — a child, a dependent adult, an animal. The caller becomes its first Guardian in the same transaction, through an ordinary MANAGE grant with the new record as grantor, so the profile appears in `accountAccess.accounts` on the next `GET /api/auth/me` and is switched into like any other shared record. `displayName` is the only required text; `dateOfBirth` is an optional `YYYY-MM-DD` and is never synthesised from a year. `gender` is optional and takes the same three values `PATCH /api/auth/me` takes for an ordinary account (`MALE`, `FEMALE`, `OTHER`) or null; absent and null both mean not recorded, and the value is what the cycle module derives its default from, so a record created for a child who does not need it starts with it off. `locale` and `timezone` belong to the RECORD and not to the Guardian reading it: they decide how the profile's own reminders are worded and when its day starts. Strict — an unexpected field is a 422 rather than an ignored key. Cookie transport only and step-up gated (`requireFreshMfa`, unconditional): a Bearer caller cannot mint a credential-less person and a permanent management relationship, and an account with no second factor enrolled is refused rather than waved through. Rate-limited to ten an hour per caller.",
|
||||
"Create a health record for somebody who has no login of their own — a child, a dependent adult, an animal. The caller becomes its first Guardian in the same transaction, through an ordinary MANAGE grant with the new record as grantor, so the profile appears in `accountAccess.accounts` on the next `GET /api/auth/me` and is switched into like any other shared record. `displayName` is the only required text; `dateOfBirth` is an optional `YYYY-MM-DD` and is never synthesised from a year. `gender` is optional and takes the same three values `PATCH /api/auth/me` takes for an ordinary account (`MALE`, `FEMALE`, `OTHER`) or null; absent and null both mean not recorded, and the value is what the cycle module derives its default from, so a record created for a child who does not need it starts with it off. `locale` and `timezone` belong to the RECORD and not to the Guardian reading it: they decide how the profile's own reminders are worded and when its day starts. Strict — an unexpected field is a 422 rather than an ignored key. Cookie transport only and step-up gated (`requireFreshMfa`, unconditional): a Bearer caller cannot mint a credential-less person and a permanent management relationship, and an account with no second factor enrolled is refused rather than waved through. A primary passkey counts as that factor here: a passkey sign-in or a passkey re-proof at POST /api/auth/reproof stamps the session. Rate-limited to ten an hour per caller.",
|
||||
});
|
||||
|
||||
const updateManagedProfileRequest = updateManagedProfileSchema.meta({
|
||||
@@ -1089,7 +1089,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"401": {
|
||||
description:
|
||||
"No fresh second-factor proof (`meta.errorCode: auth.stepup.required`, with `meta.methods` naming the proofs that clear it at POST /api/auth/reproof), or none enrolled at all (`meta.errorCode: auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account without a second factor cannot create a managed profile until it enrols one. The ordinary unauthenticated 401 arrives here too, including for a Bearer caller, whose transport this gate does not read.",
|
||||
"No fresh second-factor proof (`meta.errorCode: auth.stepup.required`, with `meta.methods` naming the proofs that clear it at POST /api/auth/reproof), or none enrolled at all (`meta.errorCode: auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account with neither a second factor nor a passkey cannot create a managed profile until it enrols one. A passkey counts: an account whose only strong credential is a passkey is offered `passkey` in `meta.methods`. The ordinary unauthenticated 401 arrives here too, including for a Bearer caller, whose transport this gate does not read.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* #959 — a passkey counts as the second factor on the managed-profile acts.
|
||||
*
|
||||
* A Guardian whose only strong credential is a primary passkey (a password
|
||||
* too, but no TOTP and no security key) used to be refused
|
||||
* `auth.stepup.mfa_not_enrolled` by `requireFreshMfa`, and so could not hand a
|
||||
* profile over, create one or manage its guardians. Driven against real
|
||||
* Postgres through the real routes, with only the WebAuthn signature check
|
||||
* stubbed:
|
||||
*
|
||||
* 1. the password login is unchanged: no MFA challenge, a session straight
|
||||
* away, and that session is not stamped as a fresh factor;
|
||||
* 2. the handover refuses with `auth.stepup.required` and offers the passkey
|
||||
* (not the password) as the way through;
|
||||
* 3. a password re-proof does not open the gate;
|
||||
* 4. a passkey re-proof, which is what the dialog posts, does, and the
|
||||
* handover goes through.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { 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 { handoverCreateBody } from "@/lib/queries/use-managed-profiles";
|
||||
|
||||
process.env.API_TOKEN_HMAC_KEY ??=
|
||||
"test-hmac-key-managed-profile-passkey-step-up-32-bytes-min";
|
||||
|
||||
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/notifications/dispatch-localised", () => ({
|
||||
dispatchLocalisedNotification: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
// The signature check is the one piece a test cannot produce honestly; the
|
||||
// challenge lookup, the ownership check and the session stamp all run.
|
||||
vi.mock("@/lib/auth/passkey", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/auth/passkey")>(
|
||||
"@/lib/auth/passkey",
|
||||
);
|
||||
return { ...actual, verifyAuthentication: vi.fn() };
|
||||
});
|
||||
|
||||
const { hashPassword } = await import("@/lib/auth/password");
|
||||
|
||||
const APP = "http://localhost:3000";
|
||||
const PASSWORD = "Correct horse battery staple!42";
|
||||
const EMAIL = "guardian@example.test";
|
||||
const CREDENTIAL_ID = "guardian-primary-passkey";
|
||||
|
||||
type RouteFn = (request: NextRequest) => Promise<Response>;
|
||||
|
||||
function post(path: string, body?: unknown): NextRequest {
|
||||
return new NextRequest(`${APP}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const { POST } = await import("@/app/api/auth/login/route");
|
||||
return (POST as unknown as RouteFn)(
|
||||
post("/api/auth/login", { email: EMAIL, password: PASSWORD }),
|
||||
);
|
||||
}
|
||||
|
||||
async function reproof(body: unknown) {
|
||||
const { POST } = await import("@/app/api/auth/reproof/route");
|
||||
return (POST as unknown as RouteFn)(post("/api/auth/reproof", body));
|
||||
}
|
||||
|
||||
async function handover(profileId: string) {
|
||||
const { POST } =
|
||||
await import("@/app/api/managed-profiles/[id]/handover/route");
|
||||
return POST(
|
||||
post(
|
||||
`/api/managed-profiles/${profileId}/handover`,
|
||||
handoverCreateBody({ profileId, expiresInDays: 7, proposals: [] }),
|
||||
),
|
||||
{ params: Promise.resolve({ id: profileId }) },
|
||||
);
|
||||
}
|
||||
|
||||
async function body(res: Response) {
|
||||
return (await res.json()) as {
|
||||
data?: unknown;
|
||||
meta?: { errorCode?: string; methods?: string[]; mfaRequired?: boolean };
|
||||
};
|
||||
}
|
||||
|
||||
async function passkeyProof(userId: string) {
|
||||
const challenge = await getPrismaClient().authChallenge.create({
|
||||
data: {
|
||||
userId,
|
||||
challenge: `proof-${Date.now()}`,
|
||||
type: "authentication",
|
||||
expiresAt: new Date(Date.now() + 5 * 60_000),
|
||||
},
|
||||
});
|
||||
return {
|
||||
method: "passkey",
|
||||
challengeId: challenge.id,
|
||||
credential: {
|
||||
id: CREDENTIAL_ID,
|
||||
rawId: CREDENTIAL_ID,
|
||||
type: "public-key",
|
||||
response: {
|
||||
clientDataJSON: "client-data",
|
||||
authenticatorData: "authenticator-data",
|
||||
signature: "signature",
|
||||
},
|
||||
clientExtensionResults: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let guardianId = "";
|
||||
let profileId = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.authChallenge.deleteMany();
|
||||
await prisma.rateLimit.deleteMany();
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
process.env.APP_URL = APP;
|
||||
process.env.NEXT_PUBLIC_APP_URL = APP;
|
||||
|
||||
const guardian = await prisma.user.create({
|
||||
data: {
|
||||
username: "guardian",
|
||||
email: EMAIL,
|
||||
passwordHash: await hashPassword(PASSWORD),
|
||||
onboardingCompletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
guardianId = guardian.id;
|
||||
await prisma.passkey.create({
|
||||
data: {
|
||||
userId: guardian.id,
|
||||
credentialId: CREDENTIAL_ID,
|
||||
credentialPublicKey: Buffer.from([4, 3, 2, 1]),
|
||||
counter: 0,
|
||||
credentialDeviceType: "multiDevice",
|
||||
credentialBackedUp: true,
|
||||
transports: ["internal"],
|
||||
},
|
||||
});
|
||||
const { profile } = await createManagedProfile({
|
||||
creatorId: guardian.id,
|
||||
displayName: "Robin",
|
||||
dateOfBirth: null,
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
});
|
||||
profileId = profile.id;
|
||||
|
||||
const { verifyAuthentication } = await import("@/lib/auth/passkey");
|
||||
vi.mocked(verifyAuthentication).mockResolvedValue({
|
||||
verification: { verified: true },
|
||||
passkey: { userId: guardian.id },
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("a Guardian whose only strong credential is a passkey", () => {
|
||||
it("signs in with the password exactly as before, without an MFA challenge", async () => {
|
||||
const res = await login();
|
||||
expect(res.status).toBe(200);
|
||||
expect((await body(res)).meta?.mfaRequired).toBeUndefined();
|
||||
expect(cookieJar.get("healthlog_session")).toBeDefined();
|
||||
const session = await getPrismaClient().session.findFirstOrThrow({
|
||||
where: { userId: guardianId },
|
||||
select: { mfaVerifiedAt: true },
|
||||
});
|
||||
// The password login proves no factor, so it cannot open the gate itself.
|
||||
expect(session.mfaVerifiedAt).toBeNull();
|
||||
expect(await getPrismaClient().mfaChallenge.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("is offered the passkey, refused on a password, and hands over after the passkey", async () => {
|
||||
expect((await login()).status).toBe(200);
|
||||
|
||||
const refused = await handover(profileId);
|
||||
expect(refused.status).toBe(401);
|
||||
expect((await body(refused)).meta).toMatchObject({
|
||||
errorCode: "auth.stepup.required",
|
||||
methods: ["passkey"],
|
||||
});
|
||||
|
||||
// A password re-proof is accepted by the endpoint (the recent-proof gate
|
||||
// still takes it on this account) but stamps no factor, so the handover
|
||||
// stays shut.
|
||||
const byPassword = await reproof({
|
||||
method: "password",
|
||||
password: PASSWORD,
|
||||
});
|
||||
expect(byPassword.status).toBe(200);
|
||||
expect((await handover(profileId)).status).toBe(401);
|
||||
|
||||
// What the dialog posts after the passkey ceremony.
|
||||
const byPasskey = await reproof(await passkeyProof(guardianId));
|
||||
expect(byPasskey.status).toBe(200);
|
||||
|
||||
const minted = await handover(profileId);
|
||||
expect(minted.status).toBe(201);
|
||||
expect(
|
||||
await getPrismaClient().managedProfileHandover.count({
|
||||
where: { profileId },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user