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:
Marc-André Bombeck committed 2026-10-29 08:56:51 +01:00
1 parent 35a0ceb12c
commit bbef4eea99
35 files changed
+1654 -187

No files matched your search

+36 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+2 -3
View File
@@ -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
+4 -1
View File
@@ -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",
+7 -1
View File
@@ -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,
});
});
+116 -33
View File
@@ -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");
}
+118
View File
@@ -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;
}
+2
View File
@@ -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",
+281
View File
@@ -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();
});
});
+72 -17
View File
@@ -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,
+2 -1
View File
@@ -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", {
+4 -2
View File
@@ -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(
+26 -4
View File
@@ -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;
}
+158
View File
@@ -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
View File
@@ -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,
};
}
+28 -7
View File
@@ -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;
+20 -3
View File
@@ -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,
]),
},
},
},
+316
View File
@@ -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();
});
});