diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 479a40268..7eae195e0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/messages/de.json b/messages/de.json index 0fe8a7223..09da70eff 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 3f99d31fe..fab5619d0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 4677c684f..1236f59ef 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 76a13377a..fe9d0a531 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 0585ad838..490af61d9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index 2a2b86dd3..1437b35ae 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": "토큰 이름", diff --git a/messages/nl.json b/messages/nl.json index e93adec7c..6d62e60fc 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 2055df78e..6dae52955 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index ae8919d20..f7762533c 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -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", diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index f865925fc..e43f8a5c3 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -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, diff --git a/src/__tests__/baselines/test-as-never.json b/src/__tests__/baselines/test-as-never.json index 330b6293f..98d2db1c7 100644 --- a/src/__tests__/baselines/test-as-never.json +++ b/src/__tests__/baselines/test-as-never.json @@ -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, diff --git a/src/__tests__/openapi-route-coverage-guard.test.ts b/src/__tests__/openapi-route-coverage-guard.test.ts index d07b2f9c8..de9421d6e 100644 --- a/src/__tests__/openapi-route-coverage-guard.test.ts +++ b/src/__tests__/openapi-route-coverage-guard.test.ts @@ -308,6 +308,10 @@ const UNPUBLISHED: Readonly> = { 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 diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts index 7467f1473..62a5b6fd2 100644 --- a/src/__tests__/session-surface-guard.test.ts +++ b/src/__tests__/session-surface-guard.test.ts @@ -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", diff --git a/src/app/api/auth/me/mfa/route.ts b/src/app/api/auth/me/mfa/route.ts index d283a10d8..703fbf091 100644 --- a/src/app/api/auth/me/mfa/route.ts +++ b/src/app/api/auth/me/mfa/route.ts @@ -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, }); }); diff --git a/src/app/api/auth/oidc/callback/route.ts b/src/app/api/auth/oidc/callback/route.ts index bfe808080..ae12a38b8 100644 --- a/src/app/api/auth/oidc/callback/route.ts +++ b/src/app/api/auth/oidc/callback/route.ts @@ -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>, + config: NonNullable>, + ip: string, + back: (reason: string | null) => NextResponse, +): Promise { + 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 { + // 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"); +} diff --git a/src/app/api/auth/oidc/reproof/route.ts b/src/app/api/auth/oidc/reproof/route.ts new file mode 100644 index 000000000..4b9f1d881 --- /dev/null +++ b/src/app/api/auth/oidc/reproof/route.ts @@ -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; +}); diff --git a/src/app/api/auth/passkey/register-options/route.ts b/src/app/api/auth/passkey/register-options/route.ts index de277bc69..968d9948a 100644 --- a/src/app/api/auth/passkey/register-options/route.ts +++ b/src/app/api/auth/passkey/register-options/route.ts @@ -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 { + 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 { 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 }); -}); +} diff --git a/src/components/auth/oidc-reproof-return.tsx b/src/components/auth/oidc-reproof-return.tsx new file mode 100644 index 000000000..8f9aa15a7 --- /dev/null +++ b/src/components/auth/oidc-reproof-return.tsx @@ -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; +} diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 1c141b66e..2549b61b0 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -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. */} + {children} diff --git a/src/components/settings/security-section/__tests__/oidc-reproof-choice.test.ts b/src/components/settings/security-section/__tests__/oidc-reproof-choice.test.ts new file mode 100644 index 000000000..9131bb3ba --- /dev/null +++ b/src/components/settings/security-section/__tests__/oidc-reproof-choice.test.ts @@ -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(); + }); +}); diff --git a/src/components/settings/security-section/existing-factor-reauth-dialog.tsx b/src/components/settings/security-section/existing-factor-reauth-dialog.tsx index 357e8d4a1..799c30f33 100644 --- a/src/components/settings/security-section/existing-factor-reauth-dialog.tsx +++ b/src/components/settings/security-section/existing-factor-reauth-dialog.tsx @@ -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 = { 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" && ( +

{t("settings.passkeyReauth.oidcHint")}

+ )} + {shown && (
)} - {t("settings.passkeyReauth.verifyAndContinue")} + {method === "oidc" + ? t("settings.passkeyReauth.continueToProvider") + : t("settings.passkeyReauth.verifyAndContinue")} diff --git a/src/components/settings/security-section/index.tsx b/src/components/settings/security-section/index.tsx index 6008b7d75..646ecb538 100644 --- a/src/components/settings/security-section/index.tsx +++ b/src/components/settings/security-section/index.tsx @@ -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} /> - + )} diff --git a/src/components/settings/security-section/passkey-list-section.tsx b/src/components/settings/security-section/passkey-list-section.tsx index 34d9ed2cb..24b860bc5 100644 --- a/src/components/settings/security-section/passkey-list-section.tsx +++ b/src/components/settings/security-section/passkey-list-section.tsx @@ -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 { + 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[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(() => {}); + } + 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(null); const [editName, setEditName] = useState(""); const [reauthOpen, setReauthOpen] = useState(false); - const [reauthMethod, setReauthMethod] = useState("password"); + const choices = passkeyProofChoices(proofMethods); + const [reauthMethod, setReauthMethod] = useState( + 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 = { + 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 = { 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"); + } + } }} > @@ -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) => ( ))}
+ {reauthMethod === "oidc" && ( +

{t("settings.passkeyReauth.oidcHint")}

+ )} + {reauthMethod === "password" && ( ({ + findUser: vi.fn<(args: unknown) => Promise>(), + updateSession: vi.fn<(args: unknown) => Promise>(), + countKeys: vi.fn<(args: unknown) => Promise>(), + countPasskeys: vi.fn<(args: unknown) => Promise>(), +})); +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 = {}) { + 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(); + }); +}); diff --git a/src/lib/auth/__tests__/oidc.test.ts b/src/lib/auth/__tests__/oidc.test.ts index d2f821cdc..d254ca0fe 100644 --- a/src/lib/auth/__tests__/oidc.test.ts +++ b/src/lib/auth/__tests__/oidc.test.ts @@ -37,6 +37,15 @@ const ENV_KEYS = [ ] as const; const ORIGINAL_ENV: Record = {}; +/** + * A verification result carrying only what `verifyIdToken` reads: the claims + * and the header `jose` returns beside them. + */ +type VerifyResult = Awaited>; +function verified(payload: Record): 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, diff --git a/src/lib/auth/existing-factor-proof.ts b/src/lib/auth/existing-factor-proof.ts index 9f1374e1d..a98a59490 100644 --- a/src/lib/auth/existing-factor-proof.ts +++ b/src/lib/auth/existing-factor-proof.ts @@ -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 { await auditLog("auth.mfa.failed", { diff --git a/src/lib/auth/guards/fresh-mfa.ts b/src/lib/auth/guards/fresh-mfa.ts index 3945a1a69..c452ad953 100644 --- a/src/lib/auth/guards/fresh-mfa.ts +++ b/src/lib/auth/guards/fresh-mfa.ts @@ -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( diff --git a/src/lib/auth/oidc-callback/state.ts b/src/lib/auth/oidc-callback/state.ts index 1cc56fd3e..b21171579 100644 --- a/src/lib/auth/oidc-callback/state.ts +++ b/src/lib/auth/oidc-callback/state.ts @@ -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; } diff --git a/src/lib/auth/oidc-reproof.ts b/src/lib/auth/oidc-reproof.ts new file mode 100644 index 000000000..a84e03948 --- /dev/null +++ b/src/lib/auth/oidc-reproof.ts @@ -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=`. 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; + ipAddress: string | null; + nowMs?: number; +}): Promise { + 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 }; +} diff --git a/src/lib/auth/oidc.ts b/src/lib/auth/oidc.ts index 1c5b86f56..08ab084a0 100644 --- a/src/lib/auth/oidc.ts +++ b/src/lib/auth/oidc.ts @@ -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, }; } diff --git a/src/lib/auth/second-factor.ts b/src/lib/auth/second-factor.ts index 4eeccf4f0..b41184971 100644 --- a/src/lib/auth/second-factor.ts +++ b/src/lib/auth/second-factor.ts @@ -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; diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 78a4217fb..05a213b7e 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -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 = { 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 = { 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 = { required: true, content: { "application/json": { - schema: z.union([passkeyReauthRequest, stepUpMintSchema]), + schema: z.union([ + passkeyReauthRequest, + passkeyProviderProofRequest, + stepUpMintSchema, + ]), }, }, }, diff --git a/tests/integration/oidc-reproof.test.ts b/tests/integration/oidc-reproof.test.ts new file mode 100644 index 000000000..7d8beb9e7 --- /dev/null +++ b/tests/integration/oidc-reproof.test.ts @@ -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(); + 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; + +async function startSession(createdAgoMs: number): Promise { + 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(); + }); +});