mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(auth): let a single-sign-on account confirm it is you at its provider
An account created through OIDC holds no password, passkey or second factor, so the recent-proof gate in front of token minting, MCP connections, exports and the admin reset had nothing to ask it for and the web could only say "sign in again" (#1296). GET /api/auth/oidc/reproof sends the browser back to the configured provider with prompt=login and max_age=0, its own state, nonce and PKCE verifier, and the signed-in account and session sealed into the state cookie. The shared callback branches on that intent: it refuses unless the browser still carries the bound session (checked before the code is redeemed), the ID token names the account's linked issuer and subject, and auth_time lies inside the gate's five-minute window. It then stamps Session.reproofAt exactly as a password re-proof does, creates no session, and returns to the page it left with ?reproof=done|failed. recentProofMethods offers "oidc" only to an account without a second factor whose identity is linked while a provider is configured, and the fresh-factor gate never lists it. GET /api/auth/me/mfa publishes the list as proofMethods, so the passkey card offers what the account can actually give, and register-options takes { method: "oidc" } on a fresh provider re-proof, so such an account can add a passkey as a second way to confirm. Both re-proof dialogs offer the provider and a toast reports the outcome on return. The callback's missing-state branch and the passkey card's enrollment flow move into their own functions to keep both handlers under the size ratchet.
This commit is contained in:
1 parent
35a0ceb12c
commit
bbef4eea99
35 files changed
+1654
-187
No files matched your search
+36
-5
@@ -2481,10 +2481,13 @@ paths:
|
||||
|
||||
The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`,
|
||||
`webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second
|
||||
factor; on one without, `password` or `passkey`. A method the account cannot use here is refused with 422
|
||||
`auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also
|
||||
refreshes the session's second-factor stamp, exactly as signing in again would. Assertions begin at POST
|
||||
/api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`.
|
||||
factor; on one without, `password` or `passkey`. An account without a second factor whose identity is linked to
|
||||
the configured identity provider is also offered `oidc` in `meta.methods`; that proof is not posted here but
|
||||
given by the browser round trip GET /api/auth/oidc/reproof, which stamps the session exactly as a password proof
|
||||
does. A method the account cannot use here is refused with 422 `auth.reproof.too_weak` and `meta.methods`,
|
||||
before any guess is spent. A second-factor or passkey proof also refreshes the session's second-factor stamp,
|
||||
exactly as signing in again would. Assertions begin at POST /api/auth/passkey/register-options with `{ method:
|
||||
"passkey" | "webauthn" }`.
|
||||
|
||||
|
||||
Proofs draw on the account's shared re-proof budget (five per fifteen minutes, 429 once spent). A proof that
|
||||
@@ -4077,7 +4080,9 @@ paths:
|
||||
|
||||
**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a
|
||||
password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options
|
||||
issued. On an account that already has a second factor a password is refused before it is checked, with 422
|
||||
issued. An account confirmed at its identity provider (GET /api/auth/oidc/reproof, within five minutes) sends `{
|
||||
method: "oidc" }` instead, which takes that re-proof as the proof and, like a password, clears the second-factor
|
||||
stamp. On an account that already has a second factor a password is refused before it is checked, with 422
|
||||
`auth.reproof.too_weak` and `meta.methods` naming the proofs that work: a passkey added on the password alone
|
||||
would sign in without the second factor from then on.
|
||||
|
||||
@@ -4103,6 +4108,7 @@ paths:
|
||||
schema:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/PasskeyReauthRequest"
|
||||
- $ref: "#/components/schemas/PasskeyProviderProofRequest"
|
||||
- $ref: "#/components/schemas/StepUpMintRequest"
|
||||
responses:
|
||||
"200":
|
||||
@@ -28611,6 +28617,17 @@ components:
|
||||
description: The re-proof arm of register-options. Recognised ONLY when `method` is the body's single key — adding any
|
||||
other property routes the request to the enrollment arm, where it will be read as a factor proof and almost
|
||||
certainly refused.
|
||||
PasskeyProviderProofRequest:
|
||||
type: object
|
||||
properties:
|
||||
method:
|
||||
type: string
|
||||
const: oidc
|
||||
required:
|
||||
- method
|
||||
description: Take the session's recent identity-provider re-proof (GET /api/auth/oidc/reproof) as the proof. Accepted
|
||||
only for an account whose `proofMethods` list `oidc` and only within five minutes of that re-proof; otherwise
|
||||
401 `auth.reproof.required` with `meta.methods`. Web only.
|
||||
PasskeyRegisterVerifyRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -47232,11 +47249,25 @@ components:
|
||||
$ref: "#/components/schemas/WebauthnMfaCredentialInfo"
|
||||
passkeyNudgeDismissed:
|
||||
type: boolean
|
||||
proofMethods:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- password
|
||||
- oidc
|
||||
- totp
|
||||
- webauthn
|
||||
- passkey
|
||||
description: The ways this account can confirm it is you, the same list a recent-proof refusal names in `meta.methods`.
|
||||
`password` and `oidc` only on an account without a second factor; `oidc` is a browser round trip to the
|
||||
identity provider (`GET /api/auth/oidc/reproof`) and is never offered to a token.
|
||||
required:
|
||||
- totp
|
||||
- recoveryCodesRemaining
|
||||
- webauthn
|
||||
- passkeyNudgeDismissed
|
||||
- proofMethods
|
||||
additionalProperties: false
|
||||
WebauthnMfaCredentialInfo:
|
||||
type: object
|
||||
|
||||
+8
-3
@@ -6237,14 +6237,17 @@
|
||||
"password": "Passwort",
|
||||
"totp": "Authenticator-Code",
|
||||
"passkey": "Passkey",
|
||||
"webauthn": "Sicherheitsschlüssel"
|
||||
"webauthn": "Sicherheitsschlüssel",
|
||||
"oidc": "Identitätsanbieter"
|
||||
},
|
||||
"currentPassword": "Aktuelles Passwort",
|
||||
"authenticatorCode": "Authenticator-Code",
|
||||
"authenticatorPlaceholder": "Sechsstelliger Authenticator-Code",
|
||||
"verifyAndAdd": "Bestätigen und hinzufügen",
|
||||
"factorDescription": "Bestätige eine vorhandene Anmeldemethode, bevor du einen zweiten Faktor hinzufügst.",
|
||||
"verifyAndContinue": "Bestätigen und fortfahren"
|
||||
"verifyAndContinue": "Bestätigen und fortfahren",
|
||||
"oidcHint": "Du meldest dich bei deinem Identitätsanbieter noch einmal an und kommst direkt auf diese Seite zurück. Dann wiederholst du, was du gerade tun wolltest.",
|
||||
"continueToProvider": "Weiter zur Anmeldung"
|
||||
},
|
||||
"passkeyName": "Name",
|
||||
"noPasskeys": "Keine Passkeys registriert.",
|
||||
@@ -7517,7 +7520,9 @@
|
||||
"description": "Damit werden deine Gesundheitsdaten weitergegeben oder exportiert. Bestätige, dass du es bist, um fortzufahren.",
|
||||
"descriptionChange": "Das ändert etwas, das nur du ändern können solltest. Bestätige, dass du es bist, um fortzufahren.",
|
||||
"signInAgain": "Melde dich neu an und versuche es dann noch einmal. Dein Konto hat kein Passwort und keinen Passkey, den du hier bestätigen könntest.",
|
||||
"failed": "Das hat nicht geklappt. Prüfe deine Eingabe und versuche es noch einmal."
|
||||
"failed": "Das hat nicht geklappt. Prüfe deine Eingabe und versuche es noch einmal.",
|
||||
"oidcDone": "Bestätigt. In den nächsten fünf Minuten kannst du weitermachen, ohne erneut gefragt zu werden.",
|
||||
"oidcFailed": "Dein Identitätsanbieter konnte nicht bestätigen, dass du es bist. Versuche es noch einmal."
|
||||
},
|
||||
"passwordTitle": "Passwort",
|
||||
"tokenNameLabel": "Token-Name",
|
||||
|
||||
+8
-3
@@ -6237,14 +6237,17 @@
|
||||
"password": "Password",
|
||||
"totp": "Authenticator code",
|
||||
"passkey": "Passkey",
|
||||
"webauthn": "Security key"
|
||||
"webauthn": "Security key",
|
||||
"oidc": "Identity provider"
|
||||
},
|
||||
"currentPassword": "Current password",
|
||||
"authenticatorCode": "Authenticator code",
|
||||
"authenticatorPlaceholder": "6-digit authenticator code",
|
||||
"verifyAndAdd": "Verify and add",
|
||||
"factorDescription": "Verify an existing sign-in method before adding a second factor.",
|
||||
"verifyAndContinue": "Verify and continue"
|
||||
"verifyAndContinue": "Verify and continue",
|
||||
"oidcHint": "You sign in once more at your identity provider and come straight back to this page. Then do what you were about to do again.",
|
||||
"continueToProvider": "Continue to sign-in"
|
||||
},
|
||||
"passkeyName": "Name",
|
||||
"noPasskeys": "No passkeys registered.",
|
||||
@@ -7517,7 +7520,9 @@
|
||||
"description": "This shares or exports your health data. Confirm it's you to continue.",
|
||||
"descriptionChange": "This changes something only you should be able to change. Confirm it's you to continue.",
|
||||
"signInAgain": "Sign in again, then try once more. Your account has no password or passkey to confirm here.",
|
||||
"failed": "That did not work. Check what you entered and try again."
|
||||
"failed": "That did not work. Check what you entered and try again.",
|
||||
"oidcDone": "Confirmed. For the next five minutes you can go ahead without being asked again.",
|
||||
"oidcFailed": "Your identity provider could not confirm it's you. Try again."
|
||||
},
|
||||
"passwordTitle": "Password",
|
||||
"tokenNameLabel": "Token name",
|
||||
|
||||
+8
-3
@@ -6237,14 +6237,17 @@
|
||||
"password": "Contraseña",
|
||||
"totp": "Código del autenticador",
|
||||
"passkey": "Clave de acceso",
|
||||
"webauthn": "Llave de seguridad"
|
||||
"webauthn": "Llave de seguridad",
|
||||
"oidc": "Proveedor de identidad"
|
||||
},
|
||||
"currentPassword": "Contraseña actual",
|
||||
"authenticatorCode": "Código del autenticador",
|
||||
"authenticatorPlaceholder": "Código de seis dígitos del autenticador",
|
||||
"verifyAndAdd": "Verificar y añadir",
|
||||
"factorDescription": "Verifica un método de inicio de sesión existente antes de añadir un segundo factor.",
|
||||
"verifyAndContinue": "Verificar y continuar"
|
||||
"verifyAndContinue": "Verificar y continuar",
|
||||
"oidcHint": "Vuelves a iniciar sesión en tu proveedor de identidad y regresas directamente a esta página. Después repite lo que ibas a hacer.",
|
||||
"continueToProvider": "Continuar al inicio de sesión"
|
||||
},
|
||||
"passkeyName": "Nombre",
|
||||
"noPasskeys": "No hay claves de acceso registradas.",
|
||||
@@ -7517,7 +7520,9 @@
|
||||
"description": "Esto comparte o exporta tus datos de salud. Confirma que eres tú para continuar.",
|
||||
"descriptionChange": "Esto cambia algo que solo tú deberías poder cambiar. Confirma que eres tú para continuar.",
|
||||
"signInAgain": "Vuelve a iniciar sesión y inténtalo de nuevo. Tu cuenta no tiene contraseña ni llave de acceso que confirmar aquí.",
|
||||
"failed": "No ha funcionado. Revisa lo que has introducido e inténtalo de nuevo."
|
||||
"failed": "No ha funcionado. Revisa lo que has introducido e inténtalo de nuevo.",
|
||||
"oidcDone": "Confirmado. Durante los próximos cinco minutos puedes continuar sin que se te vuelva a preguntar.",
|
||||
"oidcFailed": "Tu proveedor de identidad no pudo confirmar que eres tú. Inténtalo de nuevo."
|
||||
},
|
||||
"passwordTitle": "Contraseña",
|
||||
"tokenNameLabel": "Nombre del token",
|
||||
|
||||
+8
-3
@@ -6237,14 +6237,17 @@
|
||||
"password": "Mot de passe",
|
||||
"totp": "Code d’authentification",
|
||||
"passkey": "Clé d’accès",
|
||||
"webauthn": "Clé de sécurité"
|
||||
"webauthn": "Clé de sécurité",
|
||||
"oidc": "Fournisseur d'identité"
|
||||
},
|
||||
"currentPassword": "Mot de passe actuel",
|
||||
"authenticatorCode": "Code d’authentification",
|
||||
"authenticatorPlaceholder": "Code d’authentification à 6 chiffres",
|
||||
"verifyAndAdd": "Vérifier et ajouter",
|
||||
"factorDescription": "Vérifiez une méthode de connexion existante avant d'ajouter un second facteur.",
|
||||
"verifyAndContinue": "Vérifier et continuer"
|
||||
"verifyAndContinue": "Vérifier et continuer",
|
||||
"oidcHint": "Vous vous reconnectez auprès de votre fournisseur d'identité et revenez directement sur cette page. Refaites ensuite ce que vous étiez en train de faire.",
|
||||
"continueToProvider": "Continuer vers la connexion"
|
||||
},
|
||||
"passkeyName": "Nom",
|
||||
"noPasskeys": "Aucune clé d’accès enregistrée.",
|
||||
@@ -7517,7 +7520,9 @@
|
||||
"description": "Cette action partage ou exporte vos données de santé. Confirmez qu'il s'agit bien de vous pour continuer.",
|
||||
"descriptionChange": "Cela modifie quelque chose que vous seul devriez pouvoir modifier. Confirmez qu'il s'agit bien de vous pour continuer.",
|
||||
"signInAgain": "Reconnectez-vous, puis réessayez. Votre compte n'a ni mot de passe ni clé d'accès à confirmer ici.",
|
||||
"failed": "Cela n'a pas fonctionné. Vérifiez votre saisie et réessayez."
|
||||
"failed": "Cela n'a pas fonctionné. Vérifiez votre saisie et réessayez.",
|
||||
"oidcDone": "C'est confirmé. Pendant les cinq prochaines minutes, vous pouvez continuer sans qu'on vous le redemande.",
|
||||
"oidcFailed": "Votre fournisseur d'identité n'a pas pu confirmer que c'est bien vous. Réessayez."
|
||||
},
|
||||
"passwordTitle": "Mot de passe",
|
||||
"tokenNameLabel": "Nom du jeton",
|
||||
|
||||
+8
-3
@@ -6237,14 +6237,17 @@
|
||||
"password": "Password",
|
||||
"totp": "Codice dell’app di autenticazione",
|
||||
"passkey": "Passkey",
|
||||
"webauthn": "Chiave di sicurezza"
|
||||
"webauthn": "Chiave di sicurezza",
|
||||
"oidc": "Provider di identità"
|
||||
},
|
||||
"currentPassword": "Password attuale",
|
||||
"authenticatorCode": "Codice dell’app di autenticazione",
|
||||
"authenticatorPlaceholder": "Codice a 6 cifre dell’app di autenticazione",
|
||||
"verifyAndAdd": "Verifica e aggiungi",
|
||||
"factorDescription": "Verifica un metodo di accesso esistente prima di aggiungere un secondo fattore.",
|
||||
"verifyAndContinue": "Verifica e continua"
|
||||
"verifyAndContinue": "Verifica e continua",
|
||||
"oidcHint": "Accedi di nuovo presso il tuo provider di identità e torni subito a questa pagina. Poi ripeti quello che stavi per fare.",
|
||||
"continueToProvider": "Continua all'accesso"
|
||||
},
|
||||
"passkeyName": "Nome",
|
||||
"noPasskeys": "Nessuna passkey registrata.",
|
||||
@@ -7517,7 +7520,9 @@
|
||||
"description": "Questa azione condivide o esporta i tuoi dati sanitari. Conferma che sei tu per continuare.",
|
||||
"descriptionChange": "Questo modifica qualcosa che solo tu dovresti poter modificare. Conferma che sei tu per continuare.",
|
||||
"signInAgain": "Accedi di nuovo, poi riprova. Il tuo account non ha una password o una passkey da confermare qui.",
|
||||
"failed": "Non ha funzionato. Controlla quanto inserito e riprova."
|
||||
"failed": "Non ha funzionato. Controlla quanto inserito e riprova.",
|
||||
"oidcDone": "Confermato. Per i prossimi cinque minuti puoi procedere senza che ti venga chiesto di nuovo.",
|
||||
"oidcFailed": "Il tuo provider di identità non ha potuto confermare che sei tu. Riprova."
|
||||
},
|
||||
"passwordTitle": "Password",
|
||||
"tokenNameLabel": "Nome del token",
|
||||
|
||||
+8
-3
@@ -6237,14 +6237,17 @@
|
||||
"password": "비밀번호",
|
||||
"totp": "인증 앱 코드",
|
||||
"passkey": "패스키",
|
||||
"webauthn": "보안 키"
|
||||
"webauthn": "보안 키",
|
||||
"oidc": "ID 공급자"
|
||||
},
|
||||
"currentPassword": "현재 비밀번호",
|
||||
"authenticatorCode": "인증 앱 코드",
|
||||
"authenticatorPlaceholder": "6자리 인증 앱 코드",
|
||||
"verifyAndAdd": "확인 후 추가",
|
||||
"factorDescription": "두 번째 인증 수단을 추가하기 전에 기존 로그인 방법을 확인하세요.",
|
||||
"verifyAndContinue": "확인하고 계속"
|
||||
"verifyAndContinue": "확인하고 계속",
|
||||
"oidcHint": "ID 공급자에서 한 번 더 로그인하면 바로 이 페이지로 돌아옵니다. 그런 다음 하려던 작업을 다시 진행하세요.",
|
||||
"continueToProvider": "로그인으로 계속"
|
||||
},
|
||||
"passkeyName": "이름",
|
||||
"noPasskeys": "등록된 패스키가 없어요.",
|
||||
@@ -7517,7 +7520,9 @@
|
||||
"description": "건강 데이터를 공유하거나 내보내는 작업입니다. 계속하려면 본인임을 확인하세요.",
|
||||
"descriptionChange": "본인만 변경할 수 있어야 하는 항목을 변경합니다. 계속하려면 본인임을 확인하세요.",
|
||||
"signInAgain": "다시 로그인한 후 한 번 더 시도하세요. 이 계정에는 여기서 확인할 비밀번호나 패스키가 없습니다.",
|
||||
"failed": "확인하지 못했습니다. 입력한 내용을 확인하고 다시 시도하세요."
|
||||
"failed": "확인하지 못했습니다. 입력한 내용을 확인하고 다시 시도하세요.",
|
||||
"oidcDone": "확인되었습니다. 앞으로 5분 동안은 다시 묻지 않고 계속할 수 있습니다.",
|
||||
"oidcFailed": "ID 공급자가 본인임을 확인하지 못했습니다. 다시 시도하세요."
|
||||
},
|
||||
"passwordTitle": "비밀번호",
|
||||
"tokenNameLabel": "토큰 이름",
|
||||
|
||||
+8
-3
@@ -6237,14 +6237,17 @@
|
||||
"password": "Wachtwoord",
|
||||
"totp": "Authenticatorcode",
|
||||
"passkey": "Passkey",
|
||||
"webauthn": "Beveiligingssleutel"
|
||||
"webauthn": "Beveiligingssleutel",
|
||||
"oidc": "Identiteitsprovider"
|
||||
},
|
||||
"currentPassword": "Huidig wachtwoord",
|
||||
"authenticatorCode": "Authenticatorcode",
|
||||
"authenticatorPlaceholder": "Authenticatorcode van 6 cijfers",
|
||||
"verifyAndAdd": "Verifiëren en toevoegen",
|
||||
"factorDescription": "Verifieer een bestaande inlogmethode voordat je een tweede factor toevoegt.",
|
||||
"verifyAndContinue": "Verifiëren en doorgaan"
|
||||
"verifyAndContinue": "Verifiëren en doorgaan",
|
||||
"oidcHint": "Je logt nog een keer in bij je identiteitsprovider en komt meteen terug op deze pagina. Doe daarna opnieuw wat je wilde doen.",
|
||||
"continueToProvider": "Verder naar inloggen"
|
||||
},
|
||||
"passkeyName": "Naam",
|
||||
"noPasskeys": "Geen passkeys geregistreerd.",
|
||||
@@ -7517,7 +7520,9 @@
|
||||
"description": "Hiermee deel of exporteer je je gezondheidsgegevens. Bevestig dat jij het bent om door te gaan.",
|
||||
"descriptionChange": "Hiermee wijzig je iets wat alleen jij zou moeten kunnen wijzigen. Bevestig dat jij het bent om door te gaan.",
|
||||
"signInAgain": "Log opnieuw in en probeer het daarna nog een keer. Je account heeft geen wachtwoord of passkey waarmee je hier kunt bevestigen.",
|
||||
"failed": "Dat is niet gelukt. Controleer wat je hebt ingevoerd en probeer het opnieuw."
|
||||
"failed": "Dat is niet gelukt. Controleer wat je hebt ingevoerd en probeer het opnieuw.",
|
||||
"oidcDone": "Bevestigd. De komende vijf minuten kun je verder zonder dat het opnieuw wordt gevraagd.",
|
||||
"oidcFailed": "Je identiteitsprovider kon niet bevestigen dat jij het bent. Probeer het opnieuw."
|
||||
},
|
||||
"passwordTitle": "Wachtwoord",
|
||||
"tokenNameLabel": "Tokennaam",
|
||||
|
||||
+8
-3
@@ -6237,14 +6237,17 @@
|
||||
"password": "Hasło",
|
||||
"totp": "Kod z aplikacji uwierzytelniającej",
|
||||
"passkey": "Klucz dostępu",
|
||||
"webauthn": "Klucz bezpieczeństwa"
|
||||
"webauthn": "Klucz bezpieczeństwa",
|
||||
"oidc": "Dostawca tożsamości"
|
||||
},
|
||||
"currentPassword": "Aktualne hasło",
|
||||
"authenticatorCode": "Kod z aplikacji uwierzytelniającej",
|
||||
"authenticatorPlaceholder": "6-cyfrowy kod z aplikacji uwierzytelniającej",
|
||||
"verifyAndAdd": "Zweryfikuj i dodaj",
|
||||
"factorDescription": "Potwierdź istniejącą metodę logowania, zanim dodasz drugi składnik.",
|
||||
"verifyAndContinue": "Potwierdź i kontynuuj"
|
||||
"verifyAndContinue": "Potwierdź i kontynuuj",
|
||||
"oidcHint": "Zalogujesz się jeszcze raz u swojego dostawcy tożsamości i wrócisz od razu na tę stronę. Potem powtórz to, co chcesz zrobić.",
|
||||
"continueToProvider": "Przejdź do logowania"
|
||||
},
|
||||
"passkeyName": "Nazwa",
|
||||
"noPasskeys": "Brak zarejestrowanych kluczy dostępu.",
|
||||
@@ -7517,7 +7520,9 @@
|
||||
"description": "Ta czynność udostępnia lub eksportuje Twoje dane zdrowotne. Potwierdź, że to Ty, aby kontynuować.",
|
||||
"descriptionChange": "To zmienia coś, co tylko Ty powinieneś móc zmienić. Potwierdź, że to Ty, aby kontynuować.",
|
||||
"signInAgain": "Zaloguj się ponownie, a potem spróbuj jeszcze raz. Twoje konto nie ma hasła ani klucza dostępu do potwierdzenia tutaj.",
|
||||
"failed": "To się nie udało. Sprawdź wprowadzone dane i spróbuj ponownie."
|
||||
"failed": "To się nie udało. Sprawdź wprowadzone dane i spróbuj ponownie.",
|
||||
"oidcDone": "Potwierdzono. Przez następne pięć minut możesz kontynuować bez ponownego pytania.",
|
||||
"oidcFailed": "Twój dostawca tożsamości nie mógł potwierdzić, że to Ty. Spróbuj ponownie."
|
||||
},
|
||||
"passwordTitle": "Hasło",
|
||||
"tokenNameLabel": "Nazwa tokena",
|
||||
|
||||
+8
-3
@@ -6237,14 +6237,17 @@
|
||||
"password": "Senha",
|
||||
"totp": "Código do autenticador",
|
||||
"passkey": "Chave de acesso",
|
||||
"webauthn": "Chave de segurança"
|
||||
"webauthn": "Chave de segurança",
|
||||
"oidc": "Provedor de identidade"
|
||||
},
|
||||
"currentPassword": "Senha atual",
|
||||
"authenticatorCode": "Código do autenticador",
|
||||
"authenticatorPlaceholder": "Código do autenticador de 6 dígitos",
|
||||
"verifyAndAdd": "Verificar e adicionar",
|
||||
"factorDescription": "Verifique um método de entrada existente antes de adicionar um segundo fator.",
|
||||
"verifyAndContinue": "Verificar e continuar"
|
||||
"verifyAndContinue": "Verificar e continuar",
|
||||
"oidcHint": "Você entra mais uma vez no seu provedor de identidade e volta direto para esta página. Depois, repita o que ia fazer.",
|
||||
"continueToProvider": "Continuar para o login"
|
||||
},
|
||||
"passkeyName": "Nome",
|
||||
"noPasskeys": "Nenhuma chave de acesso registrada.",
|
||||
@@ -7517,7 +7520,9 @@
|
||||
"description": "Isto compartilha ou exporta os seus dados de saúde. Confirme que é você para continuar.",
|
||||
"descriptionChange": "Isto altera algo que só você deveria poder alterar. Confirme que é você para continuar.",
|
||||
"signInAgain": "Entre novamente e tente mais uma vez. Sua conta não tem senha nem chave de acesso para confirmar aqui.",
|
||||
"failed": "Não deu certo. Confira o que você digitou e tente de novo."
|
||||
"failed": "Não deu certo. Confira o que você digitou e tente de novo.",
|
||||
"oidcDone": "Confirmado. Nos próximos cinco minutos você pode continuar sem que isso seja pedido de novo.",
|
||||
"oidcFailed": "Seu provedor de identidade não conseguiu confirmar que é você. Tente novamente."
|
||||
},
|
||||
"passwordTitle": "Senha",
|
||||
"tokenNameLabel": "Nome do token",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 346,
|
||||
"total": 112297,
|
||||
"total": 112215,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 286,
|
||||
@@ -9,7 +9,7 @@
|
||||
"app/api/admin/settings/route.ts :: apiHandler(arg) #2": 211,
|
||||
"app/api/auth/login/route.ts :: apiHandler(arg)": 262,
|
||||
"app/api/auth/me/route.ts :: apiHandler(arg)": 386,
|
||||
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 281,
|
||||
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 258,
|
||||
"app/api/auth/register/route.ts :: apiHandler(arg)": 268,
|
||||
"app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309,
|
||||
"app/api/cycle/calendar/route.ts :: apiHandler(arg)": 230,
|
||||
@@ -217,7 +217,7 @@
|
||||
"components/settings/mood-reminder-card.tsx :: MoodReminderCard": 234,
|
||||
"components/settings/report-selection/selection-panel.tsx :: HealthRecordExportPanel": 370,
|
||||
"components/settings/score-section.tsx :: ScoreSection": 383,
|
||||
"components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 410,
|
||||
"components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 390,
|
||||
"components/settings/security-section/security-keys-card.tsx :: SecurityKeysCard": 288,
|
||||
"components/settings/security-section/totp-card.tsx :: TotpCard": 533,
|
||||
"components/settings/security-sessions-card.tsx :: SecuritySessionsCard": 245,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
|
||||
"count": 524,
|
||||
"total": 5488,
|
||||
"count": 523,
|
||||
"total": 5483,
|
||||
"entries": {
|
||||
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
|
||||
"__tests__/api/auth/mfa/verify.test.ts": 9,
|
||||
@@ -337,7 +337,6 @@
|
||||
"lib/auth/__tests__/mfa-enrollment.test.ts": 8,
|
||||
"lib/auth/__tests__/native-handoff-flow.test.ts": 10,
|
||||
"lib/auth/__tests__/oidc-native-handoff.test.ts": 13,
|
||||
"lib/auth/__tests__/oidc.test.ts": 5,
|
||||
"lib/auth/__tests__/passkey.test.ts": 7,
|
||||
"lib/auth/__tests__/profile-update-email-limit.test.ts": 5,
|
||||
"lib/auth/__tests__/profile-update.test.ts": 4,
|
||||
|
||||
@@ -308,6 +308,10 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
// #1296 — the identity-provider re-proof start. A top-level navigation from
|
||||
// the web's re-proof dialog that ends at the provider's sign-in page; the
|
||||
// native client never re-proves this way (Bearer has its own step-up).
|
||||
"/api/auth/oidc/reproof": { kind: "browserHandoff", methods: ["GET"] },
|
||||
"/api/fitbit/connect": { kind: "browserHandoff", methods: ["GET"] },
|
||||
"/api/google-health/connect": { kind: "browserHandoff", methods: ["GET"] },
|
||||
// `/api/nightscout/connect` sat here as a `browserHandoff` and is published
|
||||
|
||||
@@ -102,8 +102,11 @@ describe("S1 — the direct session-resolution set is frozen", () => {
|
||||
// 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.
|
||||
// And the identity-provider re-proof (#1296), which stamps only
|
||||
// the session the start bound into the sealed state, and only while the
|
||||
// browser still carries it.
|
||||
"app/api/auth/oidc/callback/route.ts":
|
||||
"duplicate-callback probe and the claim's live-session refusal on an otherwise anonymous route",
|
||||
"duplicate-callback probe, the claim's live-session refusal and the re-proof's session binding 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",
|
||||
|
||||
@@ -22,19 +22,21 @@ import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { countRemainingRecoveryCodes } from "@/lib/auth/mfa/recovery-codes";
|
||||
import { recentProofMethods } from "@/lib/auth/second-factor";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const [recoveryCodesRemaining, webauthn] = await Promise.all([
|
||||
const [recoveryCodesRemaining, webauthn, proofMethods] = await Promise.all([
|
||||
countRemainingRecoveryCodes(user.id),
|
||||
prisma.webauthnMfaCredential.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { id: true, name: true, createdAt: true, lastUsedAt: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
recentProofMethods(user),
|
||||
]);
|
||||
|
||||
annotate({ action: { name: "auth.mfa.status" } });
|
||||
@@ -46,5 +48,9 @@ export const GET = apiHandler(async () => {
|
||||
// The security hub also drives the passkey-upgrade nudge from this one
|
||||
// status read, so it knows whether the user has already dismissed it.
|
||||
passkeyNudgeDismissed: Boolean(user.passkeyUpgradeNudgeDismissed),
|
||||
// The ways this account can confirm it is you, the list the recent-proof
|
||||
// refusal names. The passkey card offers exactly these before adding one,
|
||||
// so an account without a password is not handed a password field.
|
||||
proofMethods,
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,11 @@ import {
|
||||
} from "@/lib/managed-profiles/claim-sso";
|
||||
import { pendingDeletionOf } from "@/lib/account-deletion/pending";
|
||||
import { isHandoverUsable } from "@/lib/managed-profiles/handover";
|
||||
import {
|
||||
isReproofSession,
|
||||
recordOidcReproof,
|
||||
reproofReturnUrl,
|
||||
} from "@/lib/auth/oidc-reproof";
|
||||
|
||||
const LOGIN_ERROR_URL = "/auth/login";
|
||||
|
||||
@@ -103,6 +108,20 @@ function deletionPendingRedirect(purgeAt: Date | null): NextResponse {
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* #1296 — a re-proof returns to the page it left, never to the sign-in page:
|
||||
* the person is still signed in, and the session they came from is the one
|
||||
* thing that leg may touch. `reason` null is the success.
|
||||
*/
|
||||
function reproofRedirect(next: string, reason: string | null): NextResponse {
|
||||
if (reason) annotate({ meta: { reason } });
|
||||
const response = NextResponse.redirect(
|
||||
reproofReturnUrl(next, reason ? "failed" : "done"),
|
||||
);
|
||||
deleteStateCookie(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* The state cookie is set with `path: "/api/auth/oidc"`; the delete must
|
||||
* repeat that path (RFC 6265 keys cookies by name+domain+path — a bare
|
||||
@@ -139,6 +158,10 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
const stored = readStoredOidcState(req.cookies.get(OIDC_STATE_COOKIE)?.value);
|
||||
const isNative = stored?.native === true;
|
||||
const isClaim = stored?.intent === "claim";
|
||||
// #1296 — a re-proof returns to the page it left (`reproofRedirect`).
|
||||
const isReproof = stored?.intent === "reproof";
|
||||
const reproofBack = (reason: string | null) =>
|
||||
reproofRedirect(stored?.next ?? "/", reason);
|
||||
|
||||
// Native-aware error redirect: the custom scheme for a decrypted native flow,
|
||||
// the web login page otherwise. Deletes the single-use state cookie either
|
||||
@@ -158,6 +181,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
};
|
||||
|
||||
const failRedirect = (reason: string): NextResponse => {
|
||||
if (isReproof) return reproofBack(reason);
|
||||
if (isClaim) return claimRedirect(reason);
|
||||
if (isNative) {
|
||||
const response = NextResponse.redirect(
|
||||
@@ -199,42 +223,11 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
// 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) {
|
||||
if (isReproof) return reproofBack("oidc_state");
|
||||
return isClaim ? claimRedirect("oidc_state") : errorRedirect("oidc_state");
|
||||
}
|
||||
|
||||
if (!stored) {
|
||||
// Defence-in-depth idempotency for a duplicated callback. The single-use
|
||||
// state cookie is set with `path: "/api/auth/oidc"` and DELETED by the
|
||||
// first callback the moment it succeeds. A second callback for the same
|
||||
// sign-in (a client/proxy retry, a double navigation, or the
|
||||
// service-worker double-request this release also fixes) therefore arrives
|
||||
// with the state cookie already gone — `stored` is null — and, because the
|
||||
// first callback set the session, it carries a live session cookie.
|
||||
// Redeeming the now-consumed authorization code again fails at the IdP and
|
||||
// surfaces `?error=oidc_failed` on a sign-in that actually succeeded. So
|
||||
// when a VALIDATED session is present, treat this as a duplicate of a
|
||||
// completed login and send the user into the app instead of erroring.
|
||||
//
|
||||
// Fail-closed is preserved for the real attack case: the code is NEVER
|
||||
// exchanged on this branch, and the session is resolved against the
|
||||
// database + its expiry (not merely "a cookie is present"). A forged,
|
||||
// replayed, or expired state from a caller with no valid session gets the
|
||||
// unchanged `oidc_state` error; a caller who already holds their own valid
|
||||
// session gains nothing here but a redirect to a page they could already
|
||||
// reach — no code redemption, no session mint, no identity change.
|
||||
const existingSession = await validateSessionFromCookieValue(
|
||||
readSessionCookie(req.cookies)?.value,
|
||||
);
|
||||
if (existingSession) {
|
||||
annotate({ meta: { reason: "duplicate_callback_authenticated" } });
|
||||
const response = NextResponse.redirect(oidcAppUrl("/"));
|
||||
// The state cookie is already consumed; delete defensively so a stray
|
||||
// blob can never outlive this response.
|
||||
deleteStateCookie(response);
|
||||
return response;
|
||||
}
|
||||
return errorRedirect("oidc_state");
|
||||
}
|
||||
if (!stored) return stateMissing(req);
|
||||
|
||||
// CSRF check — timing-safe, byte-for-byte, mirroring the Withings callback.
|
||||
if (
|
||||
@@ -242,9 +235,12 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
!timingSafeEqual(Buffer.from(state), Buffer.from(stored.state))
|
||||
) {
|
||||
annotate({ meta: { reason: "csrf" } });
|
||||
if (isReproof) return reproofBack("oidc_state");
|
||||
return isClaim ? claimRedirect("oidc_state") : errorRedirect("oidc_state");
|
||||
}
|
||||
|
||||
if (isReproof) return finishReproof(req, stored, config, ip, reproofBack);
|
||||
|
||||
// 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
|
||||
@@ -408,3 +404,90 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
return failRedirect("oidc_failed");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* #1296 — the re-proof leg. The browser must still carry the session the
|
||||
* start bound into the state, checked before the code is redeemed; then the
|
||||
* provider's answer must name this account's linked identity and an
|
||||
* authentication inside the gate's window. Only then is `reproofAt` stamped.
|
||||
* No session is created, and every refusal lands back on the page it left.
|
||||
*/
|
||||
async function finishReproof(
|
||||
req: NextRequest,
|
||||
stored: NonNullable<ReturnType<typeof readStoredOidcState>>,
|
||||
config: NonNullable<ReturnType<typeof getOidcConfig>>,
|
||||
ip: string,
|
||||
back: (reason: string | null) => NextResponse,
|
||||
): Promise<NextResponse> {
|
||||
const live = await validateSessionFromCookieValue(
|
||||
readSessionCookie(req.cookies)?.value,
|
||||
);
|
||||
if (!isReproofSession(stored, live)) return back("session_mismatch");
|
||||
|
||||
try {
|
||||
const metadata = await discoverOidcMetadata(config);
|
||||
const tokens = await exchangeCodeForTokens({
|
||||
metadata,
|
||||
config,
|
||||
code: req.nextUrl.searchParams.get("code") as string,
|
||||
codeVerifier: stored.codeVerifier,
|
||||
redirectUri: getOidcRedirectUri(),
|
||||
});
|
||||
if (!tokens.id_token) return back("oidc_failed");
|
||||
const identity = await verifyIdToken({
|
||||
metadata,
|
||||
config,
|
||||
idToken: tokens.id_token,
|
||||
nonce: stored.nonce,
|
||||
});
|
||||
const outcome = await recordOidcReproof({
|
||||
userId: stored.reproofUserId as string,
|
||||
sessionId: stored.reproofSessionId as string,
|
||||
issuer: metadata.issuer,
|
||||
identity,
|
||||
ipAddress: ip,
|
||||
});
|
||||
return back(outcome.ok ? null : outcome.reason);
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
return back("exchange_or_verify_failed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A callback that arrives without its state blob: the duplicate of a sign-in
|
||||
* that already completed, or a forged, replayed or expired one.
|
||||
*/
|
||||
async function stateMissing(req: NextRequest): Promise<NextResponse> {
|
||||
// Defence-in-depth idempotency for a duplicated callback. The single-use
|
||||
// state cookie is set with `path: "/api/auth/oidc"` and DELETED by the
|
||||
// first callback the moment it succeeds. A second callback for the same
|
||||
// sign-in (a client/proxy retry, a double navigation, or the
|
||||
// service-worker double-request this release also fixes) therefore arrives
|
||||
// with the state cookie already gone — `stored` is null — and, because the
|
||||
// first callback set the session, it carries a live session cookie.
|
||||
// Redeeming the now-consumed authorization code again fails at the IdP and
|
||||
// surfaces `?error=oidc_failed` on a sign-in that actually succeeded. So
|
||||
// when a VALIDATED session is present, treat this as a duplicate of a
|
||||
// completed login and send the user into the app instead of erroring.
|
||||
//
|
||||
// Fail-closed is preserved for the real attack case: the code is NEVER
|
||||
// exchanged on this branch, and the session is resolved against the
|
||||
// database + its expiry (not merely "a cookie is present"). A forged,
|
||||
// replayed, or expired state from a caller with no valid session gets the
|
||||
// unchanged `oidc_state` error; a caller who already holds their own valid
|
||||
// session gains nothing here but a redirect to a page they could already
|
||||
// reach — no code redemption, no session mint, no identity change.
|
||||
const existingSession = await validateSessionFromCookieValue(
|
||||
readSessionCookie(req.cookies)?.value,
|
||||
);
|
||||
if (existingSession) {
|
||||
annotate({ meta: { reason: "duplicate_callback_authenticated" } });
|
||||
const response = NextResponse.redirect(oidcAppUrl("/"));
|
||||
// The state cookie is already consumed; delete defensively so a stray
|
||||
// blob can never outlive this response.
|
||||
deleteStateCookie(response);
|
||||
return response;
|
||||
}
|
||||
return errorRedirect("oidc_state");
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { getClientIp } from "@/lib/api-response";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
|
||||
import { recentProofMethods } from "@/lib/auth/second-factor";
|
||||
import { throttleReproof } from "@/lib/auth/existing-factor-proof";
|
||||
import {
|
||||
buildAuthorizationUrl,
|
||||
discoverOidcMetadata,
|
||||
getOidcConfig,
|
||||
getOidcRedirectUri,
|
||||
sanitizeOidcNextPath,
|
||||
} from "@/lib/auth/oidc";
|
||||
import {
|
||||
OIDC_STATE_COOKIE,
|
||||
OIDC_STATE_COOKIE_PATH,
|
||||
OIDC_STATE_TTL_MS,
|
||||
} from "@/lib/auth/oidc-cookie";
|
||||
import { OIDC_REPROOF_STAGE, reproofReturnUrl } from "@/lib/auth/oidc-reproof";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Start confirming it is you at the identity provider (#1296).
|
||||
*
|
||||
* A top-level navigation from the re-proof dialog, cookie session only: a
|
||||
* Bearer token never re-proves this way, because a stamp on a session row
|
||||
* means nothing to a token (`requireCookieAuth` reads the session cookie and
|
||||
* nothing else). Offered only where `recentProofMethods` lists `oidc`, which
|
||||
* is an account without a second factor whose identity is linked while a
|
||||
* provider is configured; anything else goes straight back with
|
||||
* `reproof=failed`.
|
||||
*
|
||||
* The round trip asks for a fresh authentication (`prompt=login`,
|
||||
* `max_age=0`) with its own state, nonce and PKCE verifier, sealed into the
|
||||
* same single-use cookie the sign-in uses, together with the account and the
|
||||
* session it confirms. The callback stamps that session's `reproofAt` and
|
||||
* returns to `next` (a same-origin path) with `reproof=done`. It never mints
|
||||
* a session.
|
||||
*
|
||||
* Draws on the account's shared re-proof budget, like every other proof.
|
||||
*/
|
||||
export const GET = apiHandler(async (req: NextRequest) => {
|
||||
annotate({ action: { name: "auth.oidc.reproof.start" } });
|
||||
const { user, session } = await requireCookieAuth();
|
||||
|
||||
const next = sanitizeOidcNextPath(
|
||||
req.nextUrl.searchParams.get("next"),
|
||||
req.url,
|
||||
);
|
||||
const back = (reason: string): NextResponse => {
|
||||
annotate({ meta: { reason } });
|
||||
return NextResponse.redirect(reproofReturnUrl(next, "failed"));
|
||||
};
|
||||
|
||||
const config = getOidcConfig();
|
||||
if (!config) return back("oidc_disabled");
|
||||
|
||||
const methods = await recentProofMethods(user);
|
||||
if (!methods.includes("oidc")) return back("not_offered");
|
||||
|
||||
const limited = await throttleReproof(
|
||||
user.id,
|
||||
getClientIp(req),
|
||||
OIDC_REPROOF_STAGE,
|
||||
);
|
||||
if (limited) return back("rate_limited");
|
||||
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await discoverOidcMetadata(config);
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
return back("oidc_failed");
|
||||
}
|
||||
|
||||
const state = randomBytes(32).toString("base64url");
|
||||
const nonce = randomBytes(32).toString("base64url");
|
||||
const codeVerifier = randomBytes(64).toString("base64url");
|
||||
|
||||
const response = NextResponse.redirect(
|
||||
buildAuthorizationUrl({
|
||||
metadata,
|
||||
config,
|
||||
state,
|
||||
nonce,
|
||||
codeChallenge: s256Challenge(codeVerifier),
|
||||
redirectUri: getOidcRedirectUri(),
|
||||
reauthenticate: true,
|
||||
}),
|
||||
);
|
||||
response.cookies.set(
|
||||
OIDC_STATE_COOKIE,
|
||||
encrypt(
|
||||
JSON.stringify({
|
||||
state,
|
||||
nonce,
|
||||
codeVerifier,
|
||||
next,
|
||||
intent: "reproof" as const,
|
||||
reproofUserId: user.id,
|
||||
reproofSessionId: session.id,
|
||||
}),
|
||||
),
|
||||
{
|
||||
httpOnly: true,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
sameSite: "lax",
|
||||
maxAge: Math.floor(OIDC_STATE_TTL_MS / 1000),
|
||||
path: OIDC_STATE_COOKIE_PATH,
|
||||
},
|
||||
);
|
||||
return response;
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
} from "@/lib/auth/passkey";
|
||||
import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
|
||||
import {
|
||||
RECENT_PROOF_MAX_AGE_MS,
|
||||
REPROOF_REQUIRED_CODE,
|
||||
isSecondFactorProof,
|
||||
recordReproofFailure,
|
||||
refundReproof,
|
||||
@@ -59,6 +61,30 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiSuccess({ ...result, reauth: true });
|
||||
}
|
||||
|
||||
// #1296 — an account whose only credential is its identity provider proves
|
||||
// itself there (`GET /api/auth/oidc/reproof`), which stamps
|
||||
// `Session.reproofAt`. `{ method: "oidc" }` asks this route to take that
|
||||
// stamp as the proof: only where `oidc` is on the account's list (no second
|
||||
// factor, a linked identity, a configured provider) and only inside the
|
||||
// five-minute window. Like a password, it clears the MFA stamp rather than
|
||||
// setting it.
|
||||
if (Object.keys(body).length === 1 && body.method === "oidc") {
|
||||
if (
|
||||
!(await recentProofMethods(user)).includes("oidc") ||
|
||||
!(await hasRecentReproof(session.id))
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "auth.reproof.required" },
|
||||
meta: { stage: "passkey_enroll", method: "oidc" },
|
||||
});
|
||||
return apiError("Confirm it is you before adding a passkey", 401, {
|
||||
errorCode: REPROOF_REQUIRED_CODE,
|
||||
methods: await recentProofMethods(user),
|
||||
});
|
||||
}
|
||||
return issueRegistrationOptions(user, session.id, null);
|
||||
}
|
||||
|
||||
const parsed = stepUpMintSchema.safeParse(body);
|
||||
if (!parsed.success) return proofRequired();
|
||||
|
||||
@@ -101,23 +127,48 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
await refundReproof(user.id);
|
||||
|
||||
// A strong existing factor refreshes the session's MFA stamp. Password proof
|
||||
// deliberately clears it: registration is authorized by this single-use,
|
||||
// session-bound challenge but cannot upgrade password-only authentication.
|
||||
return issueRegistrationOptions(
|
||||
user,
|
||||
session.id,
|
||||
parsed.data.method === "password" ? null : new Date(),
|
||||
);
|
||||
});
|
||||
|
||||
/** A password or provider re-proof on this session inside the window. */
|
||||
async function hasRecentReproof(sessionId: string): Promise<boolean> {
|
||||
const row = await prisma.session.findUnique({
|
||||
where: { id: sessionId },
|
||||
select: { reproofAt: true },
|
||||
});
|
||||
return (
|
||||
row?.reproofAt != null &&
|
||||
Date.now() - row.reproofAt.getTime() <= RECENT_PROOF_MAX_AGE_MS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A strong existing factor refreshes the session's MFA stamp. A password or
|
||||
* provider proof deliberately clears it (`mfaVerifiedAt: null`): registration
|
||||
* is authorized by this single-use, session-bound challenge but cannot upgrade
|
||||
* first-factor authentication.
|
||||
*/
|
||||
async function issueRegistrationOptions(
|
||||
user: { id: string; email: string | null; username: string },
|
||||
sessionId: string,
|
||||
mfaVerifiedAt: Date | null,
|
||||
): Promise<Response> {
|
||||
await prisma.session.update({
|
||||
where: { id: session.id },
|
||||
data: {
|
||||
mfaVerifiedAt: parsed.data.method === "password" ? null : new Date(),
|
||||
},
|
||||
where: { id: sessionId },
|
||||
data: { mfaVerifiedAt },
|
||||
});
|
||||
|
||||
const { options, challengeId } = await createRegistrationOptions(
|
||||
user.id,
|
||||
user.email ?? user.username,
|
||||
session.id,
|
||||
sessionId,
|
||||
);
|
||||
|
||||
annotate({ action: { name: "auth.passkey.register-options" } });
|
||||
|
||||
return apiSuccess({ options, challengeId });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { dropParams, writeUrlState } from "@/lib/url-state";
|
||||
|
||||
/**
|
||||
* The outcome a confirmation at the identity provider came back with
|
||||
* (`?reproof=done|failed`, set by the OIDC callback). Pure, so the parsing is
|
||||
* testable without a browser.
|
||||
*/
|
||||
export function readReproofReturn(search: string): "done" | "failed" | null {
|
||||
const raw = new URLSearchParams(search).get("reproof");
|
||||
return raw === "done" || raw === "failed" ? raw : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Says once how the confirmation at the identity provider went, then takes the
|
||||
* marker off the address so a reload does not say it again. The browser lands
|
||||
* on the page it left, which is where the person repeats what they were
|
||||
* about to do.
|
||||
*/
|
||||
export function OidcReproofReturnBridge() {
|
||||
const { t } = useTranslations();
|
||||
useEffect(() => {
|
||||
const outcome = readReproofReturn(window.location.search);
|
||||
if (!outcome) return;
|
||||
writeUrlState(dropParams("reproof"), { mode: "replace", state: "keep" });
|
||||
toastWrittenOutcome(
|
||||
outcome === "done" ? "success" : "failed",
|
||||
outcome === "done"
|
||||
? t("settings.reproof.oidcDone")
|
||||
: t("settings.reproof.oidcFailed"),
|
||||
);
|
||||
// A later run (a locale switch) finds no marker and does nothing.
|
||||
}, [t]);
|
||||
return null;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { VersionPoller } from "@/components/version-poller";
|
||||
import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar";
|
||||
import { SharedRecordGrantLossBridge } from "@/components/layout/shared-record-grant-loss-bridge";
|
||||
import { RecordSessionFenceBridge } from "@/components/layout/record-session-fence-bridge";
|
||||
import { OidcReproofReturnBridge } from "@/components/auth/oidc-reproof-return";
|
||||
import { useAuth, type AccountAccessStatus } from "@/hooks/use-auth";
|
||||
import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
|
||||
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
@@ -432,6 +433,7 @@ export function Providers({
|
||||
in its own account, rather than asking every call site to. */}
|
||||
<SharedRecordGrantLossBridge />
|
||||
<RecordSessionFenceBridge />
|
||||
<OidcReproofReturnBridge />
|
||||
{children}
|
||||
<AppToaster />
|
||||
<KeyboardInsetBridge />
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* The web side of confirming at the identity provider (#1296): the dialogs
|
||||
* offer it when the server names it, the round trip starts from and returns
|
||||
* to the page the person is on, and the outcome marker is read once.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { oidcReproofStartUrl } from "../existing-factor-reauth-dialog";
|
||||
import { passkeyProofChoices } from "../passkey-list-section";
|
||||
import { createRecentProofFlow } from "../recent-proof-flow";
|
||||
import { readReproofReturn } from "@/components/auth/oidc-reproof-return";
|
||||
|
||||
describe("passkeyProofChoices", () => {
|
||||
it("offers only what the account can give once its list is known", () => {
|
||||
expect(passkeyProofChoices(["oidc"])).toEqual(["oidc"]);
|
||||
expect(passkeyProofChoices(["password", "oidc", "passkey"])).toEqual([
|
||||
"password",
|
||||
"oidc",
|
||||
"passkey",
|
||||
]);
|
||||
expect(passkeyProofChoices(["totp", "something-new"])).toEqual(["totp"]);
|
||||
});
|
||||
|
||||
it("falls back to the four body proofs while the list is loading", () => {
|
||||
expect(passkeyProofChoices(undefined)).toEqual([
|
||||
"password",
|
||||
"totp",
|
||||
"passkey",
|
||||
"webauthn",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("oidcReproofStartUrl", () => {
|
||||
it("names the current page as the way back, without an old outcome", () => {
|
||||
const href = oidcReproofStartUrl({
|
||||
pathname: "/settings/api",
|
||||
search: "?tab=mcp&reproof=failed",
|
||||
});
|
||||
const url = new URL(href, "https://healthlog.example.test");
|
||||
expect(url.pathname).toBe("/api/auth/oidc/reproof");
|
||||
expect(url.searchParams.get("next")).toBe("/settings/api?tab=mcp");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readReproofReturn", () => {
|
||||
it("reads a known outcome off the query string", () => {
|
||||
expect(readReproofReturn("?tab=mcp&reproof=done")).toBe("done");
|
||||
expect(readReproofReturn("?reproof=failed")).toBe("failed");
|
||||
});
|
||||
|
||||
it("ignores an address without a known outcome", () => {
|
||||
expect(readReproofReturn("")).toBeNull();
|
||||
expect(readReproofReturn("?reproof=maybe")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the re-proof dialog flow", () => {
|
||||
it("opens with the identity provider when that is the account's only way", async () => {
|
||||
const setMethods = vi.fn();
|
||||
const flow = createRecentProofFlow({
|
||||
view: { setMethods, setPending: vi.fn(), setError: vi.fn() },
|
||||
submit: vi.fn(),
|
||||
messages: () => ({ signInAgain: "sign in again", failed: "failed" }),
|
||||
});
|
||||
void flow
|
||||
.run(async () => {
|
||||
throw new ApiError("Confirm it is you", 401, {
|
||||
errorCode: "auth.reproof.required",
|
||||
methods: ["oidc"],
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
await vi.waitFor(() => expect(setMethods).toHaveBeenCalledWith(["oidc"]));
|
||||
flow.cancel();
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,30 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
|
||||
export type ReauthMethod = "password" | "totp" | "passkey" | "webauthn";
|
||||
export type ReauthMethod =
|
||||
"password" | "oidc" | "totp" | "passkey" | "webauthn";
|
||||
|
||||
/**
|
||||
* Where the browser goes to confirm at the identity provider: the re-proof
|
||||
* start, told to come back to the page it is on now. A `reproof` outcome left
|
||||
* on the current address from an earlier round trip is dropped, so the page
|
||||
* does not come back announcing the old one.
|
||||
*/
|
||||
export function oidcReproofStartUrl(current: {
|
||||
pathname: string;
|
||||
search: string;
|
||||
}): string {
|
||||
const params = new URLSearchParams(current.search);
|
||||
params.delete("reproof");
|
||||
const query = params.toString();
|
||||
const next = `${current.pathname}${query ? `?${query}` : ""}`;
|
||||
return `/api/auth/oidc/reproof?${new URLSearchParams({ next }).toString()}`;
|
||||
}
|
||||
|
||||
/** Leave for the identity provider. The page unloads; nothing resolves. */
|
||||
export function startOidcReproof(): void {
|
||||
window.location.assign(oidcReproofStartUrl(window.location));
|
||||
}
|
||||
|
||||
/** The body the enrollment routes accept as a fresh existing-factor proof. */
|
||||
export type ExistingFactorProof =
|
||||
@@ -46,23 +69,19 @@ export function isReproofRequired(err: unknown): boolean {
|
||||
|
||||
const REAUTH_METHODS: readonly ReauthMethod[] = [
|
||||
"password",
|
||||
"oidc",
|
||||
"totp",
|
||||
"passkey",
|
||||
"webauthn",
|
||||
];
|
||||
|
||||
/**
|
||||
* The proofs the refusal says this account can give, or null when it names
|
||||
* none. On an account with a second factor the server leaves the password out,
|
||||
* so the dialog does not offer a choice that can only be refused.
|
||||
*/
|
||||
/**
|
||||
* The refusal says the account holds nothing it could re-prove here: an
|
||||
* account that signs in through an identity provider only, with no password,
|
||||
* passkey or second factor. A dialog would only offer it a password it does
|
||||
* not have, so the callers say "sign in again" instead — a fresh sign-in is
|
||||
* the proof such an account can give (`Session.createdAt` within five
|
||||
* minutes).
|
||||
* The refusal says the account holds nothing it could re-prove here: no
|
||||
* password, passkey or second factor, and no identity provider to confirm at
|
||||
* (an account whose provider the operator has since switched off). A dialog
|
||||
* would only offer it a password it does not have, so the callers say "sign
|
||||
* in again" instead — a fresh sign-in is the proof such an account can give
|
||||
* (`Session.createdAt` within five minutes).
|
||||
*/
|
||||
export function nothingToReprove(err: unknown): boolean {
|
||||
return (
|
||||
@@ -72,6 +91,11 @@ export function nothingToReprove(err: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The proofs the refusal says this account can give, or null when it names
|
||||
* none. On an account with a second factor the server leaves the password out,
|
||||
* so the dialog does not offer a choice that can only be refused.
|
||||
*/
|
||||
export function offeredReauthMethods(err: unknown): ReauthMethod[] | null {
|
||||
if (!(err instanceof ApiError)) return null;
|
||||
const raw = err.meta?.methods;
|
||||
@@ -87,7 +111,9 @@ export function offeredReauthMethods(err: unknown): ReauthMethod[] | null {
|
||||
* new factor is added. A passkey or security-key assertion begins at the
|
||||
* cookie-only `/api/auth/passkey/register-options` re-proof arm, which issues
|
||||
* an assertion challenge and nothing else; the resulting proof is handed to
|
||||
* `onProof`, which retries the enrollment call with it.
|
||||
* `onProof`, which retries the enrollment call with it. The identity provider
|
||||
* is not a proof in the body: choosing it leaves the page for the provider and
|
||||
* comes back to it, and the action is then repeated by hand.
|
||||
*/
|
||||
export function ExistingFactorReauthDialog({
|
||||
open,
|
||||
@@ -117,6 +143,11 @@ export function ExistingFactorReauthDialog({
|
||||
|
||||
async function submit() {
|
||||
setAssertionError(null);
|
||||
if (method === "oidc") {
|
||||
setAsserting(true);
|
||||
startOidcReproof();
|
||||
return;
|
||||
}
|
||||
if (method === "password") {
|
||||
onProof({ method, password: secret });
|
||||
return;
|
||||
@@ -152,6 +183,7 @@ export function ExistingFactorReauthDialog({
|
||||
|
||||
const labels: Record<ReauthMethod, string> = {
|
||||
password: t("settings.passkeyReauth.methods.password"),
|
||||
oidc: t("settings.passkeyReauth.methods.oidc"),
|
||||
totp: t("settings.passkeyReauth.methods.totp"),
|
||||
passkey: t("settings.passkeyReauth.methods.passkey"),
|
||||
webauthn: t("settings.passkeyReauth.methods.webauthn"),
|
||||
@@ -223,6 +255,10 @@ export function ExistingFactorReauthDialog({
|
||||
/>
|
||||
)}
|
||||
|
||||
{method === "oidc" && (
|
||||
<p className="text-sm">{t("settings.passkeyReauth.oidcHint")}</p>
|
||||
)}
|
||||
|
||||
{shown && (
|
||||
<div
|
||||
role="alert"
|
||||
@@ -253,7 +289,9 @@ export function ExistingFactorReauthDialog({
|
||||
{busy && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.passkeyReauth.verifyAndContinue")}
|
||||
{method === "oidc"
|
||||
? t("settings.passkeyReauth.continueToProvider")
|
||||
: t("settings.passkeyReauth.verifyAndContinue")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -20,6 +20,7 @@ interface MfaStatus {
|
||||
recoveryCodesRemaining: number;
|
||||
webauthn: WebauthnKeyInfo[];
|
||||
passkeyNudgeDismissed: boolean;
|
||||
proofMethods: string[];
|
||||
}
|
||||
|
||||
interface PasskeyInfo {
|
||||
@@ -71,7 +72,10 @@ export function SecuritySection() {
|
||||
totpEnabled={status.totp.enabled}
|
||||
/>
|
||||
|
||||
<PasskeyListSection isAuthenticated={isAuthenticated} />
|
||||
<PasskeyListSection
|
||||
isAuthenticated={isAuthenticated}
|
||||
proofMethods={status.proofMethods}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -45,6 +45,11 @@ import {
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
import { describeStepUp } from "./security-keys-card";
|
||||
import {
|
||||
isReproofRequired,
|
||||
startOidcReproof,
|
||||
type ReauthMethod,
|
||||
} from "./existing-factor-reauth-dialog";
|
||||
import { ReproofCancelledError, useRecentProof } from "./use-recent-proof";
|
||||
|
||||
interface PasskeyInfo {
|
||||
@@ -56,17 +61,102 @@ interface PasskeyInfo {
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
type ReauthMethod = "password" | "passkey" | "totp" | "webauthn";
|
||||
|
||||
type ExistingFactorProof =
|
||||
| { method: "password"; password: string }
|
||||
| { method: "totp"; code: string }
|
||||
| { method: "oidc" }
|
||||
| {
|
||||
method: "passkey" | "webauthn";
|
||||
challengeId: string;
|
||||
credential: unknown;
|
||||
};
|
||||
|
||||
/** What the card offers while the account's own list has not arrived. */
|
||||
const FALLBACK_METHODS: readonly ReauthMethod[] = [
|
||||
"password",
|
||||
"totp",
|
||||
"passkey",
|
||||
"webauthn",
|
||||
];
|
||||
|
||||
/**
|
||||
* The proofs to offer before adding a passkey: the account's own list when the
|
||||
* status read has it, so an account without a password is not handed a
|
||||
* password field, and the four body proofs otherwise.
|
||||
*/
|
||||
export function passkeyProofChoices(
|
||||
proofMethods: readonly string[] | undefined,
|
||||
): ReauthMethod[] {
|
||||
if (!proofMethods) return [...FALLBACK_METHODS];
|
||||
const known: readonly ReauthMethod[] = [
|
||||
"password",
|
||||
"oidc",
|
||||
"totp",
|
||||
"passkey",
|
||||
"webauthn",
|
||||
];
|
||||
return proofMethods.filter((m): m is ReauthMethod =>
|
||||
known.includes(m as ReauthMethod),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prove an existing factor, then run the registration ceremony. The proof
|
||||
* finishes before the first registration challenge is requested, so a
|
||||
* cancelled or failed proof leaves no enrollable challenge behind.
|
||||
*/
|
||||
async function addPasskey(method: ReauthMethod, secret: string): Promise<void> {
|
||||
const webauthn = await import("@simplewebauthn/browser");
|
||||
let freshFactor: ExistingFactorProof;
|
||||
|
||||
if (method === "oidc") {
|
||||
// No secret: the proof is a provider re-proof already on the session.
|
||||
freshFactor = { method };
|
||||
} else if (method === "password") {
|
||||
freshFactor = { method, password: secret };
|
||||
} else if (method === "totp") {
|
||||
freshFactor = { method, code: secret };
|
||||
} else {
|
||||
const assertionChallenge = await apiPost<{
|
||||
options: Parameters<
|
||||
typeof webauthn.startAuthentication
|
||||
>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
}>("/api/auth/passkey/register-options", { method });
|
||||
const credential = await webauthn.startAuthentication({
|
||||
optionsJSON: assertionChallenge.options,
|
||||
});
|
||||
freshFactor = {
|
||||
method,
|
||||
challengeId: assertionChallenge.challengeId,
|
||||
credential,
|
||||
};
|
||||
}
|
||||
|
||||
let begun: {
|
||||
options: Parameters<typeof webauthn.startRegistration>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
};
|
||||
try {
|
||||
begun = await apiPost("/api/auth/passkey/register-options", freshFactor);
|
||||
} catch (err) {
|
||||
// No provider re-proof on the session yet: go and get one. The page
|
||||
// unloads and comes back here, so the mutation stays pending rather
|
||||
// than flashing an error on the way out.
|
||||
if (method !== "oidc" || !isReproofRequired(err)) throw err;
|
||||
startOidcReproof();
|
||||
return new Promise<never>(() => {});
|
||||
}
|
||||
const { options, challengeId } = begun;
|
||||
const credential = await webauthn.startRegistration({
|
||||
optionsJSON: options,
|
||||
});
|
||||
await apiPost("/api/auth/passkey/register-verify", {
|
||||
challengeId,
|
||||
credential,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary-passkey management: list (name, device, backup, created, last used),
|
||||
* add (registration ceremony), rename, and delete. The passwordless-primary
|
||||
@@ -74,8 +164,11 @@ type ExistingFactorProof =
|
||||
*/
|
||||
export function PasskeyListSection({
|
||||
isAuthenticated,
|
||||
proofMethods,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
/** `proofMethods` from `GET /api/auth/me/mfa`. */
|
||||
proofMethods?: readonly string[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -83,7 +176,10 @@ export function PasskeyListSection({
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [reauthOpen, setReauthOpen] = useState(false);
|
||||
const [reauthMethod, setReauthMethod] = useState<ReauthMethod>("password");
|
||||
const choices = passkeyProofChoices(proofMethods);
|
||||
const [reauthMethod, setReauthMethod] = useState<ReauthMethod>(
|
||||
choices[0] ?? "password",
|
||||
);
|
||||
const [reauthSecret, setReauthSecret] = useState("");
|
||||
|
||||
const { data: passkeys } = useQuery({
|
||||
@@ -93,56 +189,18 @@ export function PasskeyListSection({
|
||||
});
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: async ({
|
||||
mutationFn: ({
|
||||
method,
|
||||
secret,
|
||||
}: {
|
||||
method: ReauthMethod;
|
||||
secret: string;
|
||||
}) => {
|
||||
const webauthn = await import("@simplewebauthn/browser");
|
||||
let freshFactor: ExistingFactorProof;
|
||||
|
||||
if (method === "password") {
|
||||
freshFactor = { method, password: secret };
|
||||
} else if (method === "totp") {
|
||||
freshFactor = { method, code: secret };
|
||||
} else {
|
||||
const assertionChallenge = await apiPost<{
|
||||
options: Parameters<
|
||||
typeof webauthn.startAuthentication
|
||||
>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
}>("/api/auth/passkey/register-options", { method });
|
||||
const credential = await webauthn.startAuthentication({
|
||||
optionsJSON: assertionChallenge.options,
|
||||
});
|
||||
freshFactor = {
|
||||
method,
|
||||
challengeId: assertionChallenge.challengeId,
|
||||
credential,
|
||||
};
|
||||
}
|
||||
|
||||
const { options, challengeId } = await apiPost<{
|
||||
options: Parameters<
|
||||
typeof webauthn.startRegistration
|
||||
>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
}>("/api/auth/passkey/register-options", freshFactor);
|
||||
const credential = await webauthn.startRegistration({
|
||||
optionsJSON: options,
|
||||
});
|
||||
await apiPost("/api/auth/passkey/register-verify", {
|
||||
challengeId,
|
||||
credential,
|
||||
});
|
||||
},
|
||||
}) => addPasskey(method, secret),
|
||||
onSuccess: () => {
|
||||
setMsg(null);
|
||||
setReauthOpen(false);
|
||||
setReauthSecret("");
|
||||
setReauthMethod("password");
|
||||
setReauthMethod(choices[0] ?? "password");
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
|
||||
toastWrittenOutcome("success", t("settings.passkeyAddedToast"));
|
||||
},
|
||||
@@ -195,6 +253,14 @@ export function PasskeyListSection({
|
||||
},
|
||||
});
|
||||
|
||||
const methodLabels: Record<ReauthMethod, string> = {
|
||||
password: t("settings.passkeyReauth.methods.password"),
|
||||
oidc: t("settings.passkeyReauth.methods.oidc"),
|
||||
totp: t("settings.passkeyReauth.methods.totp"),
|
||||
passkey: t("settings.passkeyReauth.methods.passkey"),
|
||||
webauthn: t("settings.passkeyReauth.methods.webauthn"),
|
||||
};
|
||||
|
||||
const DEVICE_TYPE_LABELS: Record<string, string> = {
|
||||
singleDevice: t("settings.singleDevice"),
|
||||
multiDevice: t("settings.multiDevice"),
|
||||
@@ -350,7 +416,14 @@ export function PasskeyListSection({
|
||||
onOpenChange={(open) => {
|
||||
if (add.isPending) return;
|
||||
setReauthOpen(open);
|
||||
if (open) setMsg(null);
|
||||
if (open) {
|
||||
setMsg(null);
|
||||
// The choice the account's list leads with, in case the list
|
||||
// arrived after the card first rendered.
|
||||
if (!choices.includes(reauthMethod)) {
|
||||
setReauthMethod(choices[0] ?? "password");
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SettingsCardActions>
|
||||
@@ -384,14 +457,7 @@ export function PasskeyListSection({
|
||||
role="group"
|
||||
aria-label={t("settings.passkeyReauth.methodLabel")}
|
||||
>
|
||||
{(
|
||||
[
|
||||
["password", t("settings.passkeyReauth.methods.password")],
|
||||
["totp", t("settings.passkeyReauth.methods.totp")],
|
||||
["passkey", t("settings.passkeyReauth.methods.passkey")],
|
||||
["webauthn", t("settings.passkeyReauth.methods.webauthn")],
|
||||
] satisfies Array<[ReauthMethod, string]>
|
||||
).map(([method, label]) => (
|
||||
{choices.map((method) => (
|
||||
<Button
|
||||
key={method}
|
||||
type="button"
|
||||
@@ -407,11 +473,15 @@ export function PasskeyListSection({
|
||||
setMsg(null);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{methodLabels[method]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{reauthMethod === "oidc" && (
|
||||
<p className="text-sm">{t("settings.passkeyReauth.oidcHint")}</p>
|
||||
)}
|
||||
|
||||
{reauthMethod === "password" && (
|
||||
<Input
|
||||
type="password"
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
|
||||
const KNOWN_METHODS: readonly ReauthMethod[] = [
|
||||
"password",
|
||||
"oidc",
|
||||
"totp",
|
||||
"webauthn",
|
||||
"passkey",
|
||||
@@ -93,7 +94,9 @@ export function createRecentProofFlow(deps: {
|
||||
if (!isProofRefusal(err)) throw err;
|
||||
const offered = methodsFrom(err as ApiError);
|
||||
// Nothing to re-prove in place: an account that signs in through an
|
||||
// identity provider only. A fresh sign-in is the proof it can give.
|
||||
// identity provider the instance no longer offers. A fresh sign-in is
|
||||
// the proof it can give. One that can still reach its provider is
|
||||
// offered `oidc` instead, which leaves the page and comes back to it.
|
||||
if (offered.length === 0) {
|
||||
throw new ApiError(deps.messages().signInAgain, 401, {
|
||||
errorCode: "auth.reproof.sign_in_again",
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Confirming at the identity provider (#1296): which accounts are offered it,
|
||||
* what the callback accepts as fresh, whose identity counts, and what the
|
||||
* sealed state must carry.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
findUser: vi.fn<(args: unknown) => Promise<unknown>>(),
|
||||
updateSession: vi.fn<(args: unknown) => Promise<unknown>>(),
|
||||
countKeys: vi.fn<(args: unknown) => Promise<number>>(),
|
||||
countPasskeys: vi.fn<(args: unknown) => Promise<number>>(),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: db.findUser },
|
||||
session: { update: db.updateSession },
|
||||
webauthnMfaCredential: { count: db.countKeys },
|
||||
passkey: { count: db.countPasskeys },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
refundRateLimit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((s: string) => s),
|
||||
encrypt: vi.fn((s: string) => s),
|
||||
}));
|
||||
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { refundRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
isFirstFactorOnlyProof,
|
||||
recentProofMethods,
|
||||
} from "@/lib/auth/second-factor";
|
||||
import {
|
||||
isFreshAuthTime,
|
||||
isLinkedIdentity,
|
||||
isReproofSession,
|
||||
recordOidcReproof,
|
||||
reproofReturnUrl,
|
||||
} from "@/lib/auth/oidc-reproof";
|
||||
import { readStoredOidcState } from "@/lib/auth/oidc-callback/state";
|
||||
|
||||
const ISSUER = "https://idp.example.test";
|
||||
const OIDC_ENV = {
|
||||
OIDC_ISSUER_URL: ISSUER,
|
||||
OIDC_CLIENT_ID: "client",
|
||||
OIDC_CLIENT_SECRET: "secret",
|
||||
};
|
||||
|
||||
function accountRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
passwordHash: null,
|
||||
oidcIssuer: ISSUER,
|
||||
oidcSub: "sub-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.assign(process.env, OIDC_ENV);
|
||||
process.env.NEXT_PUBLIC_APP_URL = "https://healthlog.example.test";
|
||||
db.countKeys.mockResolvedValue(0);
|
||||
db.countPasskeys.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of Object.keys(OIDC_ENV)) delete process.env[key];
|
||||
});
|
||||
|
||||
describe("recentProofMethods offers the identity provider", () => {
|
||||
const user = { id: "u1", totpConfirmedAt: null };
|
||||
|
||||
it("to an account without a second factor whose identity is linked", async () => {
|
||||
db.findUser.mockResolvedValue(accountRow());
|
||||
expect(await recentProofMethods(user, false)).toEqual(["oidc"]);
|
||||
});
|
||||
|
||||
it("beside the password and passkey the account also holds", async () => {
|
||||
db.countPasskeys.mockResolvedValue(1);
|
||||
db.findUser.mockResolvedValue(accountRow({ passwordHash: "hash" }));
|
||||
expect(await recentProofMethods(user, false)).toEqual([
|
||||
"password",
|
||||
"oidc",
|
||||
"passkey",
|
||||
]);
|
||||
});
|
||||
|
||||
it("not to an account without a linked identity", async () => {
|
||||
db.findUser.mockResolvedValue(
|
||||
accountRow({ oidcIssuer: null, oidcSub: null }),
|
||||
);
|
||||
expect(await recentProofMethods(user, false)).toEqual([]);
|
||||
});
|
||||
|
||||
it("not when the instance has no provider configured", async () => {
|
||||
delete process.env.OIDC_CLIENT_SECRET;
|
||||
db.findUser.mockResolvedValue(accountRow());
|
||||
expect(await recentProofMethods(user, false)).toEqual([]);
|
||||
});
|
||||
|
||||
it("not to an account with a second factor, where it could not clear the gate", async () => {
|
||||
db.countKeys.mockResolvedValue(1);
|
||||
db.findUser.mockResolvedValue(accountRow());
|
||||
expect(await recentProofMethods(user, true)).toEqual(["webauthn"]);
|
||||
});
|
||||
|
||||
it("and never counts it as a second factor", () => {
|
||||
expect(isFirstFactorOnlyProof("oidc")).toBe(true);
|
||||
expect(isFirstFactorOnlyProof("password")).toBe(true);
|
||||
expect(isFirstFactorOnlyProof("passkey")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFreshAuthTime", () => {
|
||||
const now = 1_800_000_000_000;
|
||||
const seconds = (ms: number) => Math.floor(ms / 1000);
|
||||
|
||||
it("accepts an authentication inside the five-minute window", () => {
|
||||
expect(isFreshAuthTime(seconds(now - 10_000), now)).toBe(true);
|
||||
expect(isFreshAuthTime(seconds(now - 300_000), now)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses one older than the window", () => {
|
||||
expect(isFreshAuthTime(seconds(now - 301_000), now)).toBe(false);
|
||||
expect(isFreshAuthTime(seconds(now - 3_600_000), now)).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a token that does not say when", () => {
|
||||
expect(isFreshAuthTime(null, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("tolerates a provider clock a minute ahead, and no more", () => {
|
||||
expect(isFreshAuthTime(seconds(now + 30_000), now)).toBe(true);
|
||||
expect(isFreshAuthTime(seconds(now + 120_000), now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLinkedIdentity", () => {
|
||||
it("needs both the issuer and the subject to match", () => {
|
||||
const account = { oidcIssuer: ISSUER, oidcSub: "sub-1" };
|
||||
expect(isLinkedIdentity(account, ISSUER, "sub-1")).toBe(true);
|
||||
expect(isLinkedIdentity(account, ISSUER, "sub-2")).toBe(false);
|
||||
expect(isLinkedIdentity(account, "https://other.test", "sub-1")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isLinkedIdentity({ oidcIssuer: null, oidcSub: null }, ISSUER, "sub-1"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isReproofSession", () => {
|
||||
const bound = { reproofUserId: "u1", reproofSessionId: "s1" };
|
||||
|
||||
it("holds only for the session and account the start bound", () => {
|
||||
expect(
|
||||
isReproofSession(bound, { session: { id: "s1" }, user: { id: "u1" } }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isReproofSession(bound, { session: { id: "s2" }, user: { id: "u1" } }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isReproofSession(bound, { session: { id: "s1" }, user: { id: "u2" } }),
|
||||
).toBe(false);
|
||||
expect(isReproofSession(bound, null)).toBe(false);
|
||||
expect(
|
||||
isReproofSession({}, { session: { id: "s1" }, user: { id: "u1" } }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordOidcReproof", () => {
|
||||
const now = 1_800_000_000_000;
|
||||
const args = {
|
||||
userId: "u1",
|
||||
sessionId: "s1",
|
||||
issuer: ISSUER,
|
||||
ipAddress: "203.0.113.5",
|
||||
nowMs: now,
|
||||
};
|
||||
|
||||
it("stamps reproofAt only, refunds the attempt and audits the success", async () => {
|
||||
db.findUser.mockResolvedValue(accountRow());
|
||||
const outcome = await recordOidcReproof({
|
||||
...args,
|
||||
identity: { sub: "sub-1", authTime: Math.floor(now / 1000) - 5 },
|
||||
});
|
||||
expect(outcome).toEqual({ ok: true });
|
||||
expect(db.updateSession).toHaveBeenCalledWith({
|
||||
where: { id: "s1" },
|
||||
data: { reproofAt: new Date(now) },
|
||||
});
|
||||
expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:u1");
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.reproof.succeeded",
|
||||
expect.objectContaining({ details: { method: "oidc" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a stale auth_time without stamping", async () => {
|
||||
db.findUser.mockResolvedValue(accountRow());
|
||||
const outcome = await recordOidcReproof({
|
||||
...args,
|
||||
identity: { sub: "sub-1", authTime: Math.floor(now / 1000) - 3600 },
|
||||
});
|
||||
expect(outcome).toEqual({ ok: false, reason: "stale_auth_time" });
|
||||
expect(db.updateSession).not.toHaveBeenCalled();
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.mfa.failed",
|
||||
expect.objectContaining({
|
||||
details: {
|
||||
stage: "oidc_reproof",
|
||||
method: "oidc",
|
||||
reason: "stale_auth_time",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a different subject without stamping", async () => {
|
||||
db.findUser.mockResolvedValue(accountRow());
|
||||
const outcome = await recordOidcReproof({
|
||||
...args,
|
||||
identity: { sub: "someone-else", authTime: Math.floor(now / 1000) },
|
||||
});
|
||||
expect(outcome).toEqual({ ok: false, reason: "identity_mismatch" });
|
||||
expect(db.updateSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the re-proof return and state", () => {
|
||||
it("returns to the page it left with the outcome attached", () => {
|
||||
const url = reproofReturnUrl("/settings/api?tab=mcp", "done");
|
||||
expect(url.origin).toBe("https://healthlog.example.test");
|
||||
expect(url.pathname).toBe("/settings/api");
|
||||
expect(url.searchParams.get("tab")).toBe("mcp");
|
||||
expect(url.searchParams.get("reproof")).toBe("done");
|
||||
});
|
||||
|
||||
it("reads a reproof state only with both bindings and no other intent's fields", () => {
|
||||
const base = { state: "s", nonce: "n", codeVerifier: "v", next: "/" };
|
||||
const ok = readStoredOidcState(
|
||||
JSON.stringify({
|
||||
...base,
|
||||
intent: "reproof",
|
||||
reproofUserId: "u1",
|
||||
reproofSessionId: "s1",
|
||||
}),
|
||||
);
|
||||
expect(ok?.intent).toBe("reproof");
|
||||
expect(
|
||||
readStoredOidcState(
|
||||
JSON.stringify({ ...base, intent: "reproof", reproofUserId: "u1" }),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
readStoredOidcState(
|
||||
JSON.stringify({
|
||||
...base,
|
||||
intent: "reproof",
|
||||
reproofUserId: "u1",
|
||||
reproofSessionId: "s1",
|
||||
native: true,
|
||||
appCodeChallenge: "c",
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
readStoredOidcState(JSON.stringify({ ...base, intent: "other" })),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,15 @@ const ENV_KEYS = [
|
||||
] as const;
|
||||
const ORIGINAL_ENV: Record<string, string | undefined> = {};
|
||||
|
||||
/**
|
||||
* A verification result carrying only what `verifyIdToken` reads: the claims
|
||||
* and the header `jose` returns beside them.
|
||||
*/
|
||||
type VerifyResult = Awaited<ReturnType<typeof mockJwtVerify>>;
|
||||
function verified(payload: Record<string, unknown>): VerifyResult {
|
||||
return { payload, protectedHeader: { alg: "RS256" } } as VerifyResult;
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, ok = true, status = 200): Response {
|
||||
return {
|
||||
ok,
|
||||
@@ -270,6 +279,25 @@ describe("buildAuthorizationUrl", () => {
|
||||
}),
|
||||
);
|
||||
expect(url.searchParams.get("prompt")).toBe("login");
|
||||
expect(url.searchParams.has("max_age")).toBe(false);
|
||||
});
|
||||
|
||||
it("asks for a fresh, timed authentication for a re-proof", () => {
|
||||
configureEnv();
|
||||
const url = new URL(
|
||||
buildAuthorizationUrl({
|
||||
metadata: METADATA_DOC,
|
||||
config: getOidcConfig()!,
|
||||
state: "state-1",
|
||||
nonce: "nonce-1",
|
||||
codeChallenge: "challenge-1",
|
||||
redirectUri: "https://healthlog.example.com/api/auth/oidc/callback",
|
||||
reauthenticate: true,
|
||||
}),
|
||||
);
|
||||
expect(url.searchParams.get("prompt")).toBe("login");
|
||||
expect(url.searchParams.get("max_age")).toBe("0");
|
||||
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -332,16 +360,15 @@ describe("verifyIdToken", () => {
|
||||
beforeEach(() => configureEnv());
|
||||
|
||||
it("returns the identity claims on success", async () => {
|
||||
// Test payloads deliberately omit the full JWTVerifyResult shape.
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue({
|
||||
payload: {
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(
|
||||
verified({
|
||||
sub: "user-sub-1",
|
||||
email: "person@example.com",
|
||||
email_verified: true,
|
||||
name: "Person",
|
||||
nonce: "nonce-1",
|
||||
},
|
||||
} as never);
|
||||
}),
|
||||
);
|
||||
const config = getOidcConfig()!;
|
||||
const identity = await verifyIdToken({
|
||||
metadata: METADATA_DOC,
|
||||
@@ -354,13 +381,43 @@ describe("verifyIdToken", () => {
|
||||
email: "person@example.com",
|
||||
emailVerified: true,
|
||||
name: "Person",
|
||||
authTime: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns auth_time when the provider states it, and null otherwise", async () => {
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(
|
||||
verified({
|
||||
sub: "user-sub-1",
|
||||
nonce: "nonce-1",
|
||||
auth_time: 1_700_000_000,
|
||||
}),
|
||||
);
|
||||
const config = getOidcConfig()!;
|
||||
const stated = await verifyIdToken({
|
||||
metadata: METADATA_DOC,
|
||||
config,
|
||||
idToken: "id-token",
|
||||
nonce: "nonce-1",
|
||||
});
|
||||
expect(stated.authTime).toBe(1_700_000_000);
|
||||
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(
|
||||
verified({ sub: "user-sub-1", nonce: "nonce-1", auth_time: "recent" }),
|
||||
);
|
||||
const unstated = await verifyIdToken({
|
||||
metadata: METADATA_DOC,
|
||||
config,
|
||||
idToken: "id-token",
|
||||
nonce: "nonce-1",
|
||||
});
|
||||
expect(unstated.authTime).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a nonce mismatch", async () => {
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue({
|
||||
payload: { sub: "user-sub-1", nonce: "wrong-nonce" },
|
||||
} as never);
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(
|
||||
verified({ sub: "user-sub-1", nonce: "wrong-nonce" }),
|
||||
);
|
||||
const config = getOidcConfig()!;
|
||||
await expect(
|
||||
verifyIdToken({
|
||||
@@ -373,9 +430,7 @@ describe("verifyIdToken", () => {
|
||||
});
|
||||
|
||||
it("rejects a token missing sub", async () => {
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue({
|
||||
payload: { nonce: "nonce-1" },
|
||||
} as never);
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(verified({ nonce: "nonce-1" }));
|
||||
const config = getOidcConfig()!;
|
||||
await expect(
|
||||
verifyIdToken({
|
||||
@@ -388,9 +443,9 @@ describe("verifyIdToken", () => {
|
||||
});
|
||||
|
||||
it("verifies with a closed asymmetric algorithm allowlist and bounded clock tolerance", async () => {
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue({
|
||||
payload: { sub: "user-sub-1", nonce: "nonce-1" },
|
||||
} as never);
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(
|
||||
verified({ sub: "user-sub-1", nonce: "nonce-1" }),
|
||||
);
|
||||
const config = getOidcConfig()!;
|
||||
await verifyIdToken({
|
||||
metadata: METADATA_DOC,
|
||||
@@ -411,9 +466,9 @@ describe("verifyIdToken", () => {
|
||||
});
|
||||
|
||||
it("pins the JWKS fetch to the issuer's host with a bounded timeout", async () => {
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue({
|
||||
payload: { sub: "user-sub-1", nonce: "nonce-1" },
|
||||
} as never);
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(
|
||||
verified({ sub: "user-sub-1", nonce: "nonce-1" }),
|
||||
);
|
||||
const config = getOidcConfig()!;
|
||||
await verifyIdToken({
|
||||
metadata: METADATA_DOC,
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import {
|
||||
hasSecondFactorEnrolled,
|
||||
recentProofMethods,
|
||||
type RecentProofMethod,
|
||||
} from "@/lib/auth/second-factor";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
@@ -171,7 +172,7 @@ export async function recordReproofFailure(
|
||||
userId: string,
|
||||
ipAddress: string | null,
|
||||
stage: string,
|
||||
method: ExistingFactorProof["method"],
|
||||
method: RecentProofMethod,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
await auditLog("auth.mfa.failed", {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import {
|
||||
hasSecondFactorEnrolled,
|
||||
isFirstFactorOnlyProof,
|
||||
recentProofMethods,
|
||||
} from "@/lib/auth/second-factor";
|
||||
import { meterAccountWrite } from "@/lib/auth/write-meter";
|
||||
@@ -80,9 +81,10 @@ export async function requireFreshMfa(
|
||||
// does, so the browser can open the re-proof dialog instead of printing a
|
||||
// sentence with nowhere to act on it. Only the possession proofs: a
|
||||
// `POST /api/auth/reproof` with one of them stamps `mfaVerifiedAt`, a
|
||||
// password stamps `reproofAt` only and would leave this gate shut.
|
||||
// password or a provider sign-in stamps `reproofAt` only and would leave
|
||||
// this gate shut.
|
||||
const methods = (await recentProofMethods(sessionData.user)).filter(
|
||||
(method) => method !== "password",
|
||||
(method) => !isFirstFactorOnlyProof(method),
|
||||
);
|
||||
annotate({ meta: { step_up_methods: methods.join(",") } });
|
||||
throw new StepUpRequiredError(
|
||||
|
||||
@@ -27,8 +27,17 @@ export interface StoredOidcState {
|
||||
* names instead of signing in, and only when the cookie still carries this
|
||||
* exact binding. Web only: never together with `native`.
|
||||
*/
|
||||
intent?: "claim";
|
||||
intent?: "claim" | "reproof";
|
||||
claimBinding?: string;
|
||||
/**
|
||||
* Set only for `intent: "reproof"` (`GET /api/auth/oidc/reproof`): the
|
||||
* signed-in account and the session row the round trip confirms. The
|
||||
* callback stamps nothing unless the browser still carries exactly this
|
||||
* session and the provider names exactly this account's linked identity.
|
||||
* Web only, like the claim.
|
||||
*/
|
||||
reproofUserId?: string;
|
||||
reproofSessionId?: string;
|
||||
}
|
||||
|
||||
function isStoredOidcState(value: unknown): value is StoredOidcState {
|
||||
@@ -50,11 +59,24 @@ function isStoredOidcState(value: unknown): value is StoredOidcState {
|
||||
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) {
|
||||
if (v.native !== undefined) return false;
|
||||
if (v.intent === "claim") {
|
||||
if (typeof v.claimBinding !== "string" || v.claimBinding.length === 0) {
|
||||
return false;
|
||||
}
|
||||
} else if (v.intent === "reproof") {
|
||||
if (
|
||||
typeof v.reproofUserId !== "string" ||
|
||||
v.reproofUserId.length === 0 ||
|
||||
typeof v.reproofSessionId !== "string" ||
|
||||
v.reproofSessionId.length === 0 ||
|
||||
v.claimBinding !== undefined
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
if (v.native !== undefined) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Confirming it is you through the identity provider.
|
||||
*
|
||||
* An account created by single sign-on holds no password, passkey or second
|
||||
* factor, so the recent-proof gate (token minting, exports, share links, the
|
||||
* admin data actions) had nothing to ask it for. This is the proof such an
|
||||
* account can give: the browser goes back to the provider with
|
||||
* `prompt=login` and `max_age=0`, and the callback counts the round trip only
|
||||
* when the ID token says the provider authenticated the person inside the
|
||||
* gate's own window and names the identity linked to the signed-in account.
|
||||
*
|
||||
* It records the proof exactly as a password re-proof does, `Session.reproofAt`
|
||||
* and nothing else, so it never stands in for a second factor. It creates no
|
||||
* session and switches none: the callback stamps the session row the start
|
||||
* bound into the sealed state, and only while the browser still carries it.
|
||||
*
|
||||
* Start: `GET /api/auth/oidc/reproof?next=<path>`. Finish: the shared
|
||||
* `GET /api/auth/oidc/callback` with `intent: "reproof"` in the state blob.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { oidcAppUrl, type OidcIdentity } from "@/lib/auth/oidc";
|
||||
import {
|
||||
RECENT_PROOF_MAX_AGE_MS,
|
||||
recordReproofFailure,
|
||||
refundReproof,
|
||||
} from "@/lib/auth/existing-factor-proof";
|
||||
|
||||
/** The query parameter the browser comes back with, and its two values. */
|
||||
export const OIDC_REPROOF_RESULT_PARAM = "reproof";
|
||||
export type OidcReproofResult = "done" | "failed";
|
||||
|
||||
/** The stage name the shared re-proof bucket and the audit rows carry. */
|
||||
export const OIDC_REPROOF_STAGE = "oidc_reproof";
|
||||
|
||||
/**
|
||||
* How far in the future `auth_time` may sit and still count: the clock
|
||||
* tolerance `verifyIdToken` already grants `iat` and `exp`, for an identity
|
||||
* provider on another box whose clock runs a little ahead.
|
||||
*/
|
||||
const AUTH_TIME_FUTURE_TOLERANCE_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Did the provider authenticate the person inside the gate's window (the five
|
||||
* minutes every recent-proof surface uses)? A token
|
||||
* without `auth_time` is refused: with `max_age` in the request the provider
|
||||
* owes the claim (OpenID Connect Core 2), and without it nothing says the
|
||||
* person signed in now rather than an hour ago.
|
||||
*/
|
||||
export function isFreshAuthTime(
|
||||
authTime: number | null,
|
||||
nowMs: number,
|
||||
maxAgeMs: number = RECENT_PROOF_MAX_AGE_MS,
|
||||
): boolean {
|
||||
if (authTime === null) return false;
|
||||
const at = authTime * 1000;
|
||||
return nowMs - at <= maxAgeMs && at - nowMs <= AUTH_TIME_FUTURE_TOLERANCE_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the identity the provider named the one linked to this account? Both
|
||||
* halves, compared exactly: the subject is only unique per issuer.
|
||||
*/
|
||||
export function isLinkedIdentity(
|
||||
account: { oidcIssuer: string | null; oidcSub: string | null },
|
||||
issuer: string,
|
||||
sub: string,
|
||||
): boolean {
|
||||
return (
|
||||
account.oidcIssuer !== null &&
|
||||
account.oidcSub !== null &&
|
||||
account.oidcIssuer === issuer &&
|
||||
account.oidcSub === sub
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the browser still carry the session the round trip was started from?
|
||||
* Checked before the code is redeemed, so a mismatch spends nothing at the
|
||||
* provider.
|
||||
*/
|
||||
export function isReproofSession(
|
||||
bound: { reproofUserId?: string; reproofSessionId?: string },
|
||||
live: { session: { id: string }; user: { id: string } } | null,
|
||||
): boolean {
|
||||
return (
|
||||
live !== null &&
|
||||
typeof bound.reproofSessionId === "string" &&
|
||||
typeof bound.reproofUserId === "string" &&
|
||||
live.session.id === bound.reproofSessionId &&
|
||||
live.user.id === bound.reproofUserId
|
||||
);
|
||||
}
|
||||
|
||||
/** Where the browser lands: the page it left, with the outcome attached. */
|
||||
export function reproofReturnUrl(next: string, result: OidcReproofResult): URL {
|
||||
const url = oidcAppUrl(next);
|
||||
url.searchParams.set(OIDC_REPROOF_RESULT_PARAM, result);
|
||||
return url;
|
||||
}
|
||||
|
||||
export type OidcReproofOutcome =
|
||||
{ ok: true } | { ok: false; reason: "identity_mismatch" | "stale_auth_time" };
|
||||
|
||||
/**
|
||||
* Check the verified ID token against the account and stamp the session.
|
||||
* Every refusal is audited like a failed password re-proof; a success gives
|
||||
* back the attempt the start charged to the shared bucket.
|
||||
*/
|
||||
export async function recordOidcReproof(args: {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
issuer: string;
|
||||
identity: Pick<OidcIdentity, "sub" | "authTime">;
|
||||
ipAddress: string | null;
|
||||
nowMs?: number;
|
||||
}): Promise<OidcReproofOutcome> {
|
||||
const { userId, sessionId, issuer, identity, ipAddress } = args;
|
||||
const nowMs = args.nowMs ?? Date.now();
|
||||
|
||||
const account = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { oidcIssuer: true, oidcSub: true },
|
||||
});
|
||||
let refusal: "identity_mismatch" | "stale_auth_time" | null = null;
|
||||
if (!account || !isLinkedIdentity(account, issuer, identity.sub)) {
|
||||
refusal = "identity_mismatch";
|
||||
} else if (!isFreshAuthTime(identity.authTime, nowMs)) {
|
||||
refusal = "stale_auth_time";
|
||||
}
|
||||
if (refusal) {
|
||||
await recordReproofFailure(
|
||||
userId,
|
||||
ipAddress,
|
||||
OIDC_REPROOF_STAGE,
|
||||
"oidc",
|
||||
refusal,
|
||||
);
|
||||
return { ok: false, reason: refusal };
|
||||
}
|
||||
|
||||
await prisma.session.update({
|
||||
where: { id: sessionId },
|
||||
data: { reproofAt: new Date(nowMs) },
|
||||
});
|
||||
await refundReproof(userId);
|
||||
await auditLog("auth.reproof.succeeded", {
|
||||
userId,
|
||||
ipAddress,
|
||||
details: { method: "oidc" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.reproof.succeeded" },
|
||||
meta: { method: "oidc" },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
+20
-1
@@ -244,6 +244,13 @@ export function buildAuthorizationUrl(params: {
|
||||
* keyboard now, not a session someone else left open in this browser.
|
||||
*/
|
||||
forceLogin?: boolean;
|
||||
/**
|
||||
* Ask the provider to authenticate afresh AND to say when it did: `prompt=login`
|
||||
* plus `max_age=0` (OpenID Connect Core 3.1.2.1). With `max_age` in the
|
||||
* request the ID token must carry `auth_time`, which is what the re-proof
|
||||
* leg checks before it counts the round trip as a proof.
|
||||
*/
|
||||
reauthenticate?: boolean;
|
||||
}): string {
|
||||
const url = new URL(params.metadata.authorization_endpoint);
|
||||
url.search = new URLSearchParams({
|
||||
@@ -255,7 +262,8 @@ export function buildAuthorizationUrl(params: {
|
||||
nonce: params.nonce,
|
||||
code_challenge: params.codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
...(params.forceLogin ? { prompt: "login" } : {}),
|
||||
...(params.forceLogin || params.reauthenticate ? { prompt: "login" } : {}),
|
||||
...(params.reauthenticate ? { max_age: "0" } : {}),
|
||||
}).toString();
|
||||
return url.toString();
|
||||
}
|
||||
@@ -304,6 +312,12 @@ export interface OidcIdentity {
|
||||
email: string | null;
|
||||
emailVerified: boolean | undefined;
|
||||
name: string | null;
|
||||
/**
|
||||
* When the provider last authenticated the person (`auth_time`, seconds
|
||||
* since the epoch), or null when the token does not say. Only the re-proof
|
||||
* leg reads it, and it refuses a null.
|
||||
*/
|
||||
authTime: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -350,6 +364,11 @@ export async function verifyIdToken(params: {
|
||||
? payload.email_verified
|
||||
: undefined,
|
||||
name: typeof payload.name === "string" ? payload.name : null,
|
||||
authTime:
|
||||
typeof payload.auth_time === "number" &&
|
||||
Number.isFinite(payload.auth_time)
|
||||
? payload.auth_time
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isOidcConfigured } from "@/lib/auth/oidc";
|
||||
|
||||
/**
|
||||
* Does this account have a SECOND FACTOR at all?
|
||||
@@ -40,11 +41,25 @@ export async function hasSecondFactorEnrolled(user: {
|
||||
}
|
||||
|
||||
/**
|
||||
* A proof the recent-proof gate can ask for. `password` and `passkey` on an
|
||||
* account without a second factor; `totp`, `webauthn` and `passkey` on one
|
||||
* with a second factor, where a password alone has never been enough.
|
||||
* A proof the recent-proof gate can ask for. `password`, `oidc` and `passkey`
|
||||
* on an account without a second factor; `totp`, `webauthn` and `passkey` on
|
||||
* one with a second factor, where a password alone has never been enough.
|
||||
*
|
||||
* `oidc` is a fresh sign-in at the account's linked identity provider
|
||||
* (`GET /api/auth/oidc/reproof`), not a body proof: it stamps
|
||||
* `Session.reproofAt` exactly as a password does, and like a password it never
|
||||
* stands in for a second factor.
|
||||
*/
|
||||
export type RecentProofMethod = "password" | "totp" | "webauthn" | "passkey";
|
||||
export type RecentProofMethod =
|
||||
"password" | "oidc" | "totp" | "webauthn" | "passkey";
|
||||
|
||||
/**
|
||||
* The proofs that stamp only `Session.reproofAt` and so never clear a gate that
|
||||
* asks for a second factor (`requireFreshMfa`).
|
||||
*/
|
||||
export function isFirstFactorOnlyProof(method: RecentProofMethod): boolean {
|
||||
return method === "password" || method === "oidc";
|
||||
}
|
||||
|
||||
/**
|
||||
* The proofs that would satisfy `assertRecentCookieProof` for this
|
||||
@@ -63,15 +78,21 @@ export async function recentProofMethods(
|
||||
prisma.passkey.count({ where: { userId: user.id } }),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { passwordHash: true },
|
||||
select: { passwordHash: true, oidcIssuer: true, oidcSub: true },
|
||||
}),
|
||||
]);
|
||||
const methods: RecentProofMethod[] = [];
|
||||
if (hasSecond) {
|
||||
if (user.totpConfirmedAt) methods.push("totp");
|
||||
if (keys > 0) methods.push("webauthn");
|
||||
} else if (credential?.passwordHash) {
|
||||
methods.push("password");
|
||||
} else {
|
||||
if (credential?.passwordHash) methods.push("password");
|
||||
// A linked identity is only a way to confirm while the instance still
|
||||
// talks to a provider: with the provider group unset the round trip has
|
||||
// nowhere to go.
|
||||
if (credential?.oidcIssuer && credential.oidcSub && isOidcConfigured()) {
|
||||
methods.push("oidc");
|
||||
}
|
||||
}
|
||||
if (passkeys > 0) methods.push("passkey");
|
||||
return methods;
|
||||
|
||||
@@ -342,6 +342,11 @@ const mfaStatusResponse = z
|
||||
recoveryCodesRemaining: z.number().int(),
|
||||
webauthn: z.array(webauthnCredentialInfo),
|
||||
passkeyNudgeDismissed: z.boolean(),
|
||||
proofMethods: z
|
||||
.array(z.enum(["password", "oidc", "totp", "webauthn", "passkey"]))
|
||||
.describe(
|
||||
"The ways this account can confirm it is you, the same list a recent-proof refusal names in `meta.methods`. `password` and `oidc` only on an account without a second factor; `oidc` is a browser round trip to the identity provider (`GET /api/auth/oidc/reproof`) and is never offered to a token.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "MfaStatusResponse" });
|
||||
|
||||
@@ -599,6 +604,14 @@ const changePasswordRequest = changePasswordSchema.meta({
|
||||
"Password rotation. `newPassword` and `confirmPassword` must match, and `newPassword` must differ from the current one. The strength floor is 12 characters plus a scored check against the account's own username and email.",
|
||||
});
|
||||
|
||||
const passkeyProviderProofRequest = z
|
||||
.object({ method: z.literal("oidc") })
|
||||
.meta({
|
||||
id: "PasskeyProviderProofRequest",
|
||||
description:
|
||||
"Take the session's recent identity-provider re-proof (GET /api/auth/oidc/reproof) as the proof. Accepted only for an account whose `proofMethods` list `oidc` and only within five minutes of that re-proof; otherwise 401 `auth.reproof.required` with `meta.methods`. Web only.",
|
||||
});
|
||||
|
||||
const passkeyReauthRequest = z
|
||||
.object({
|
||||
method: z
|
||||
@@ -1130,7 +1143,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Re-prove a credential on a browser session (cookie only)",
|
||||
description:
|
||||
"Stamps the calling browser session as recently re-proved, so for the next five minutes it may run the actions that ask for a fresh proof: GET /api/export/full-backup, POST /api/export/encrypted, POST /api/share-links, POST /api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/workouts, POST /api/tokens/documents, and the admin backup download, upload and restore, the admin data wipe and the admin password reset. Those answer 401 `auth.reproof.required` with `meta.methods` (the proofs this account can give) when the session signed in or re-proved more than five minutes ago. A `totp`, `webauthn` or `passkey` proof here also clears the routes that ask for a fresh second factor (the managed-profile acts, the second-factor and passkey changes, the password change, account deletion, the data wipe and key rotation): on a browser session those answer 401 `auth.stepup.required` with `meta.methods` naming the possession proofs the account holds, and the web re-proves here and retries.\n\n" +
|
||||
'The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`, `webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second factor; on one without, `password` or `passkey`. A method the account cannot use here is refused with 422 `auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also refreshes the session\'s second-factor stamp, exactly as signing in again would. Assertions begin at POST /api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`.\n\n' +
|
||||
'The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`, `webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second factor; on one without, `password` or `passkey`. An account without a second factor whose identity is linked to the configured identity provider is also offered `oidc` in `meta.methods`; that proof is not posted here but given by the browser round trip GET /api/auth/oidc/reproof, which stamps the session exactly as a password proof does. A method the account cannot use here is refused with 422 `auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also refreshes the session\'s second-factor stamp, exactly as signing in again would. Assertions begin at POST /api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`.\n\n' +
|
||||
"Proofs draw on the account's shared re-proof budget (five per fifteen minutes, 429 once spent). A proof that does not verify is 401 `auth.reproof.failed` and is audited. Bearer callers use POST /api/auth/step-up instead.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -2454,7 +2467,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
description:
|
||||
"Cookie-only — a Bearer token cannot enroll a sign-in credential on this surface at any scope. The route has TWO arms and the body decides which.\n\n" +
|
||||
'**Re-proof arm.** A body of exactly `{ method: "passkey" }` or `{ method: "webauthn" }` begins an assertion ceremony WITHOUT beginning enrollment, and answers with `reauth: true` alongside the options. Use it to obtain the assertion the second arm needs.\n\n' +
|
||||
"**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options issued. On an account that already has a second factor a password is refused before it is checked, with 422 `auth.reproof.too_weak` and `meta.methods` naming the proofs that work: a passkey added on the password alone would sign in without the second factor from then on.\n\n" +
|
||||
'**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options issued. An account confirmed at its identity provider (GET /api/auth/oidc/reproof, within five minutes) sends `{ method: "oidc" }` instead, which takes that re-proof as the proof and, like a password, clears the second-factor stamp. On an account that already has a second factor a password is refused before it is checked, with 422 `auth.reproof.too_weak` and `meta.methods` naming the proofs that work: a passkey added on the password alone would sign in without the second factor from then on.\n\n' +
|
||||
"Which factor you prove changes the session, and the direction is not the obvious one: a strong proof stamps the session second-factor-verified, while a PASSWORD proof deliberately CLEARS that stamp. Registering with a password is authorised by the single-use, session-bound challenge alone and must not silently upgrade password-only authentication into something the rest of the app reads as a second factor.\n\n" +
|
||||
"Every refusal on the proof path is the same 401 with the same prose — a missing content type, an unparseable body, a body that is not a valid proof shape, and a wrong password are indistinguishable to the caller. A `totp` proof counts only from an authenticator whose enrollment was confirmed.\n\n" +
|
||||
"Each proof on the enrollment arm draws on the account's shared re-proof budget (five per fifteen minutes, the same budget as POST /api/auth/step-up), and every failed proof writes an `auth.mfa.failed` audit row. Once the budget is spent the answer is 429 until the window passes, even for a correct proof.",
|
||||
@@ -2462,7 +2475,11 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.union([passkeyReauthRequest, stepUpMintSchema]),
|
||||
schema: z.union([
|
||||
passkeyReauthRequest,
|
||||
passkeyProviderProofRequest,
|
||||
stepUpMintSchema,
|
||||
]),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Confirming at the identity provider (#1296), against real Postgres.
|
||||
*
|
||||
* An account created by single sign-on holds no password, passkey or second
|
||||
* factor, so the recent-proof gate in front of token minting had nothing to
|
||||
* ask it for. The browser now goes back to the provider with a fresh login
|
||||
* request, and the callback stamps the signed-in session's `reproofAt` when
|
||||
* the ID token names this account's linked identity and an authentication
|
||||
* inside the window. The provider's network legs are stubbed; the state blob,
|
||||
* the session binding, the stamp, the gate and the passkey enrollment are
|
||||
* real.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
process.env.API_TOKEN_HMAC_KEY ??=
|
||||
"test-hmac-key-oidc-reproof-32-bytes-minimum-1234567890abcd";
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
const ISSUER = "https://idp.example.test";
|
||||
const METADATA = {
|
||||
issuer: ISSUER,
|
||||
authorization_endpoint: `${ISSUER}/authorize`,
|
||||
token_endpoint: `${ISSUER}/token`,
|
||||
jwks_uri: `${ISSUER}/jwks`,
|
||||
};
|
||||
|
||||
vi.mock("@/lib/auth/oidc", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/auth/oidc")>();
|
||||
return {
|
||||
...actual,
|
||||
discoverOidcMetadata: vi.fn(async () => METADATA),
|
||||
exchangeCodeForTokens: vi.fn(async () => ({ id_token: "id-token" })),
|
||||
verifyIdToken: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const oidc = await import("@/lib/auth/oidc");
|
||||
|
||||
const USER_ID = "user-oidc-reproof";
|
||||
const SUB = "idp-sub-reproof";
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const OIDC_ENV = {
|
||||
OIDC_ISSUER_URL: ISSUER,
|
||||
OIDC_CLIENT_ID: "client",
|
||||
OIDC_CLIENT_SECRET: "secret",
|
||||
};
|
||||
|
||||
type RouteFn = (request: NextRequest) => Promise<Response>;
|
||||
|
||||
async function startSession(createdAgoMs: number): Promise<string> {
|
||||
const now = Date.now();
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
createdAt: new Date(now - createdAgoMs),
|
||||
expiresAt: new Date(now + HOUR),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return session.id;
|
||||
}
|
||||
|
||||
/** The provider says it authenticated `sub` this many seconds ago. */
|
||||
function providerSays(sub: string, secondsAgo: number) {
|
||||
vi.mocked(oidc.verifyIdToken).mockResolvedValue({
|
||||
sub,
|
||||
email: "sso@example.test",
|
||||
emailVerified: true,
|
||||
name: null,
|
||||
authTime: Math.floor(Date.now() / 1000) - secondsAgo,
|
||||
});
|
||||
}
|
||||
|
||||
async function startReproof(next: string) {
|
||||
const { GET } = await import("@/app/api/auth/oidc/reproof/route");
|
||||
const res = await (GET as unknown as RouteFn)(
|
||||
new NextRequest(
|
||||
`http://localhost:3000/api/auth/oidc/reproof?next=${encodeURIComponent(next)}`,
|
||||
),
|
||||
);
|
||||
return res as Response & { cookies?: unknown };
|
||||
}
|
||||
|
||||
/** Run the start, then come back through the callback with the browser's cookies. */
|
||||
async function roundTrip(next: string, sessionCookie: string) {
|
||||
const started = await startReproof(next);
|
||||
expect(started.status).toBe(307);
|
||||
const authorize = new URL(started.headers.get("location")!);
|
||||
const stateCookie = /oidc_auth_state=([^;]+)/.exec(
|
||||
started.headers.get("set-cookie") ?? "",
|
||||
)?.[1];
|
||||
expect(stateCookie).toBeTruthy();
|
||||
|
||||
const { GET } = await import("@/app/api/auth/oidc/callback/route");
|
||||
const res = await (GET as unknown as RouteFn)(
|
||||
new NextRequest(
|
||||
`http://localhost:3000/api/auth/oidc/callback?code=abc&state=${authorize.searchParams.get("state")}`,
|
||||
{
|
||||
headers: {
|
||||
cookie: `oidc_auth_state=${stateCookie}; healthlog_session=${sessionCookie}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
return { authorize, res };
|
||||
}
|
||||
|
||||
async function mintMeasurementToken() {
|
||||
const { POST } = await import("@/app/api/tokens/measurements/route");
|
||||
return (POST as unknown as RouteFn)(
|
||||
new NextRequest("http://localhost:3000/api/tokens/measurements", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ name: "shortcut" }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function reproofAtOf(sessionId: string) {
|
||||
const row = await getPrismaClient().session.findUniqueOrThrow({
|
||||
where: { id: sessionId },
|
||||
select: { reproofAt: true, mfaVerifiedAt: true },
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
Object.assign(process.env, OIDC_ENV);
|
||||
process.env.APP_URL = "http://localhost:3000";
|
||||
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: USER_ID,
|
||||
username: "sso-only",
|
||||
email: "sso@example.test",
|
||||
role: "USER",
|
||||
oidcIssuer: ISSUER,
|
||||
oidcSub: SUB,
|
||||
onboardingCompletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of Object.keys(OIDC_ENV)) delete process.env[key];
|
||||
});
|
||||
|
||||
describe("confirming at the identity provider", () => {
|
||||
it("is what the gate offers an account with nothing else to prove", async () => {
|
||||
await startSession(HOUR);
|
||||
const refused = await mintMeasurementToken();
|
||||
expect(refused.status).toBe(401);
|
||||
const body = (await refused.json()) as {
|
||||
meta?: { errorCode?: string; methods?: string[] };
|
||||
};
|
||||
expect(body.meta?.errorCode).toBe("auth.reproof.required");
|
||||
expect(body.meta?.methods).toEqual(["oidc"]);
|
||||
});
|
||||
|
||||
it("asks the provider for a fresh, timed sign-in with state, nonce and PKCE", async () => {
|
||||
await startSession(HOUR);
|
||||
const started = await startReproof("/settings/api");
|
||||
const authorize = new URL(started.headers.get("location")!);
|
||||
expect(authorize.origin + authorize.pathname).toBe(
|
||||
METADATA.authorization_endpoint,
|
||||
);
|
||||
expect(authorize.searchParams.get("prompt")).toBe("login");
|
||||
expect(authorize.searchParams.get("max_age")).toBe("0");
|
||||
expect(authorize.searchParams.get("state")).toBeTruthy();
|
||||
expect(authorize.searchParams.get("nonce")).toBeTruthy();
|
||||
expect(authorize.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
});
|
||||
|
||||
it("stamps the session, returns to the page and lets the token mint", async () => {
|
||||
const sessionId = await startSession(HOUR);
|
||||
providerSays(SUB, 10);
|
||||
const { res } = await roundTrip("/settings/api?tab=tokens", sessionId);
|
||||
expect(res.status).toBe(307);
|
||||
const back = new URL(res.headers.get("location")!);
|
||||
expect(back.pathname).toBe("/settings/api");
|
||||
expect(back.searchParams.get("tab")).toBe("tokens");
|
||||
expect(back.searchParams.get("reproof")).toBe("done");
|
||||
|
||||
const row = await reproofAtOf(sessionId);
|
||||
expect(row.reproofAt).not.toBeNull();
|
||||
expect(row.mfaVerifiedAt).toBeNull();
|
||||
// No session was created or switched.
|
||||
expect(await getPrismaClient().session.count()).toBe(1);
|
||||
expect(
|
||||
await getPrismaClient().auditLog.count({
|
||||
where: { userId: USER_ID, action: "auth.reproof.succeeded" },
|
||||
}),
|
||||
).toBe(1);
|
||||
|
||||
const minted = await mintMeasurementToken();
|
||||
expect(minted.status).toBe(201);
|
||||
});
|
||||
|
||||
it("then lets the account enroll a passkey", async () => {
|
||||
const sessionId = await startSession(HOUR);
|
||||
const { POST } =
|
||||
await import("@/app/api/auth/passkey/register-options/route");
|
||||
const begin = () =>
|
||||
(POST as unknown as RouteFn)(
|
||||
new NextRequest(
|
||||
"http://localhost:3000/api/auth/passkey/register-options",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ method: "oidc" }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const before = await begin();
|
||||
expect(before.status).toBe(401);
|
||||
expect(
|
||||
((await before.json()) as { meta?: { methods?: string[] } }).meta
|
||||
?.methods,
|
||||
).toEqual(["oidc"]);
|
||||
|
||||
providerSays(SUB, 10);
|
||||
await roundTrip("/settings/security", sessionId);
|
||||
|
||||
const after = await begin();
|
||||
expect(after.status).toBe(200);
|
||||
const begun = (await after.json()) as {
|
||||
data: { challengeId: string; options: { challenge: string } };
|
||||
};
|
||||
expect(begun.data.challengeId).toBeTruthy();
|
||||
expect(begun.data.options.challenge).toBeTruthy();
|
||||
});
|
||||
|
||||
it("refuses a provider sign-in older than the window", async () => {
|
||||
const sessionId = await startSession(HOUR);
|
||||
providerSays(SUB, 60 * 60);
|
||||
const { res } = await roundTrip("/settings/api", sessionId);
|
||||
expect(
|
||||
new URL(res.headers.get("location")!).searchParams.get("reproof"),
|
||||
).toBe("failed");
|
||||
expect((await reproofAtOf(sessionId)).reproofAt).toBeNull();
|
||||
expect((await mintMeasurementToken()).status).toBe(401);
|
||||
expect(
|
||||
await getPrismaClient().auditLog.count({
|
||||
where: { userId: USER_ID, action: "auth.mfa.failed" },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses a different subject at the same provider", async () => {
|
||||
const sessionId = await startSession(HOUR);
|
||||
providerSays("someone-else", 10);
|
||||
const { res } = await roundTrip("/settings/api", sessionId);
|
||||
expect(
|
||||
new URL(res.headers.get("location")!).searchParams.get("reproof"),
|
||||
).toBe("failed");
|
||||
expect((await reproofAtOf(sessionId)).reproofAt).toBeNull();
|
||||
expect((await mintMeasurementToken()).status).toBe(401);
|
||||
});
|
||||
|
||||
it("stamps nothing and spends nothing when the browser comes back with another session", async () => {
|
||||
const sessionId = await startSession(HOUR);
|
||||
const other = await getPrismaClient().session.create({
|
||||
data: { userId: USER_ID, expiresAt: new Date(Date.now() + HOUR) },
|
||||
});
|
||||
providerSays(SUB, 10);
|
||||
const { res } = await roundTrip("/settings/api", other.id);
|
||||
expect(
|
||||
new URL(res.headers.get("location")!).searchParams.get("reproof"),
|
||||
).toBe("failed");
|
||||
expect(oidc.exchangeCodeForTokens).not.toHaveBeenCalled();
|
||||
expect((await reproofAtOf(sessionId)).reproofAt).toBeNull();
|
||||
expect((await reproofAtOf(other.id)).reproofAt).toBeNull();
|
||||
});
|
||||
|
||||
it("is not offered to an account with a second factor", async () => {
|
||||
await getPrismaClient().user.update({
|
||||
where: { id: USER_ID },
|
||||
data: { totpConfirmedAt: new Date() },
|
||||
});
|
||||
await startSession(HOUR);
|
||||
const started = await startReproof("/settings/api");
|
||||
const back = new URL(started.headers.get("location")!);
|
||||
expect(back.origin).toBe("http://localhost:3000");
|
||||
expect(back.searchParams.get("reproof")).toBe("failed");
|
||||
expect(oidc.discoverOidcMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user