mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge branch 'fix/auth-takeover-hardening' into release/v1.39.3
# Conflicts: # docs/api/openapi.yaml # messages/de.json # messages/en.json # messages/es.json # messages/fr.json # messages/it.json # messages/ko.json # messages/pl.json
This commit is contained in:
commit
42d47e0877
67 files changed
+3631
-778
No files matched your search
+509
-438
File diff suppressed because it is too large.
Load diff
+23
-14
@@ -81,11 +81,19 @@ Client registration essentials only — the IdP's own docs cover the rest.
|
||||
## Security model
|
||||
|
||||
- **Identity is pinned, not guessed.** The first OIDC sign-in either
|
||||
provisions a fresh account or links to an existing account matched by
|
||||
**verified** email (`email_verified: true` — an IdP that does not
|
||||
assert verification is rejected). Either way the account is stamped
|
||||
with the provider's `(issuer, sub)` pair, and every later login
|
||||
matches on that pair alone. An email change at the IdP updates the
|
||||
provisions a fresh account or links to an existing account with the
|
||||
same email, and only when the IdP asserts `email_verified: true` (an
|
||||
IdP that does not assert verification is rejected). An existing
|
||||
account is never linked on the email alone: HealthLog never verified
|
||||
that address, so anyone could have registered it or typed it into a
|
||||
profile. The browser is sent to the sign-in page instead, and the link
|
||||
is made when the person signs in to that account once with its own
|
||||
password (and second factor) or passkey, within ten minutes. Under
|
||||
`OIDC_ONLY` that password sign-in is allowed for exactly that account.
|
||||
The iOS app gets `oidc_link_required` and the person confirms once on
|
||||
the web. Either way the account is stamped with the provider's
|
||||
`(issuer, sub)` pair, and every later login matches on that pair
|
||||
alone. An email change at the IdP updates the
|
||||
displayed email; it can never re-point the login at a different
|
||||
account. An email that matches an account already pinned to a
|
||||
_different_ identity is rejected and audited.
|
||||
@@ -141,12 +149,13 @@ Consequences to accept before turning it on:
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
| --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Login page shows "Single sign-on failed", log says `discovery issuer mismatch` | `OIDC_ISSUER_URL` must equal the `issuer` inside the discovery document. A single trailing slash either way is tolerated; anything else (scheme, host, port, path, `//`) is a hard reject. |
|
||||
| `discovery request failed: 3xx` | Discovery redirects are not followed (egress policy pins `redirect: "manual"`). Point `OIDC_ISSUER_URL` at the final URL — e.g. include the realm/application path, match `http` vs `https`, add/remove `www`. |
|
||||
| IdP shows its own "invalid redirect URI" page | The mismatch happens **at the IdP**, before HealthLog is involved. Compare the registered URI against `${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback` byte for byte — scheme and port included. |
|
||||
| Sign-in loops back with "failed" and the log shows an `exp`/`nbf` claim error | Clock skew beyond the 60 s tolerance. Fix NTP on the IdP or app box. |
|
||||
| "Your identity provider did not mark your email address as verified" | The IdP omits `email_verified` or sends `false`. Mark the address verified at the IdP (Keycloak: user → Email verified; Authentik: verify the email stage) — HealthLog will not link or provision without it. |
|
||||
| "That email address belongs to an account that is already linked to a different SSO identity" | Deliberate: identities never re-bind silently. See "Linking is once" above. |
|
||||
| SSO button missing | One of the three provider vars is empty or not whitelisted in compose. `pnpm check-env` shows which. |
|
||||
| Symptom | Cause / fix |
|
||||
| --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Login page shows "Single sign-on failed", log says `discovery issuer mismatch` | `OIDC_ISSUER_URL` must equal the `issuer` inside the discovery document. A single trailing slash either way is tolerated; anything else (scheme, host, port, path, `//`) is a hard reject. |
|
||||
| `discovery request failed: 3xx` | Discovery redirects are not followed (egress policy pins `redirect: "manual"`). Point `OIDC_ISSUER_URL` at the final URL — e.g. include the realm/application path, match `http` vs `https`, add/remove `www`. |
|
||||
| IdP shows its own "invalid redirect URI" page | The mismatch happens **at the IdP**, before HealthLog is involved. Compare the registered URI against `${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback` byte for byte — scheme and port included. |
|
||||
| Sign-in loops back with "failed" and the log shows an `exp`/`nbf` claim error | Clock skew beyond the 60 s tolerance. Fix NTP on the IdP or app box. |
|
||||
| "Your identity provider did not mark your email address as verified" | The IdP omits `email_verified` or sends `false`. Mark the address verified at the IdP (Keycloak: user → Email verified; Authentik: verify the email stage) — HealthLog will not link or provision without it. |
|
||||
| "An account with this email address already exists here. Sign in to it once …" | The IdP email matches a local account that is not linked yet. Sign in to that account with its password or passkey within ten minutes and the link is made. If nobody knows that account's password, an operator decides which account the identity belongs to. |
|
||||
| "That email address belongs to an account that is already linked to a different SSO identity" | Deliberate: identities never re-bind silently. See "Linking is once" above. |
|
||||
| SSO button missing | One of the three provider vars is empty or not whitelisted in compose. `pnpm check-env` shows which. |
|
||||
+16
-4
@@ -128,6 +128,12 @@
|
||||
"emailInUse": "Diese E-Mail-Adresse wird bereits verwendet.",
|
||||
"emailRateLimited": "Zu viele Änderungen der E-Mail-Adresse. Versuche es später erneut."
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"reproof": {
|
||||
"required": "Bestätige dein aktuelles Passwort, um das zu ändern.",
|
||||
"failed": "Die Bestätigung ist fehlgeschlagen. Es wurde nichts geändert."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -440,7 +446,8 @@
|
||||
"errorEmailUnverified": "Dein Identitätsanbieter hat deine E-Mail-Adresse nicht als bestätigt markiert.",
|
||||
"errorIdentityConflict": "Diese E-Mail-Adresse gehört zu einem Konto, das bereits mit einer anderen SSO-Identität verknüpft ist.",
|
||||
"errorRegistrationDisabled": "Kein Konto passt zu dieser Identität, und neue Registrierungen sind derzeit geschlossen.",
|
||||
"errorFailed": "Single Sign-On ist fehlgeschlagen. Bitte versuche es erneut."
|
||||
"errorFailed": "Single Sign-On ist fehlgeschlagen. Bitte versuche es erneut.",
|
||||
"errorLinkRequired": "Hier gibt es bereits ein Konto mit dieser E-Mail-Adresse. Melde dich einmal mit seinem Passwort oder Passkey an, um es mit Single Sign-on zu verbinden."
|
||||
},
|
||||
"native": {
|
||||
"signingInToApp": "Anmeldung bei der HealthLog-App"
|
||||
@@ -5553,7 +5560,9 @@
|
||||
"invalidFormat": "Das ist nicht in einer Form geschrieben, die dieses Feld lesen kann.",
|
||||
"invalidType": "Der Wert kam nicht in einer Form an, die dieses Feld speichern kann.",
|
||||
"rateLimited": "Zu oft geändert. Warte kurz und versuche es dann erneut.",
|
||||
"other": "Dieser Wert wurde nicht angenommen. Bitte prüfen und erneut versuchen."
|
||||
"other": "Dieser Wert wurde nicht angenommen. Bitte prüfen und erneut versuchen.",
|
||||
"reproofRequired": "Gib dein aktuelles Passwort ein, um die E-Mail-Adresse zu ändern.",
|
||||
"reproofFailed": "Das Passwort stimmt nicht, die E-Mail-Adresse wurde nicht geändert."
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Profilfoto",
|
||||
@@ -5594,7 +5603,9 @@
|
||||
"currentPassword": "Aktuelles Passwort",
|
||||
"authenticatorCode": "Authenticator-Code",
|
||||
"authenticatorPlaceholder": "Sechsstelliger Authenticator-Code",
|
||||
"verifyAndAdd": "Bestätigen und hinzufügen"
|
||||
"verifyAndAdd": "Bestätigen und hinzufügen",
|
||||
"factorDescription": "Bestätige eine vorhandene Anmeldemethode, bevor du einen zweiten Faktor hinzufügst.",
|
||||
"verifyAndContinue": "Bestätigen und fortfahren"
|
||||
},
|
||||
"passkeyName": "Name",
|
||||
"noPasskeys": "Keine Passkeys registriert.",
|
||||
@@ -6727,7 +6738,8 @@
|
||||
"unavailableExplainer": "Dieser Server erlaubt keine Dokumentarchive mehr, die Verbindung unten ist daher nicht nutzbar. Du kannst sie weiterhin entfernen.",
|
||||
"moduleOffExplainer": "Dokumente sind ausgeschaltet, die Verbindung unten wird daher nicht genutzt. Du kannst sie weiterhin entfernen.",
|
||||
"tokenNewAddress": "Für eine neue Adresse wird das API-Token erneut gebraucht."
|
||||
}
|
||||
},
|
||||
"emailChangePassword": "Aktuelles Passwort (nötig, um die E-Mail-Adresse zu ändern)"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
|
||||
+16
-4
@@ -128,6 +128,12 @@
|
||||
"emailInUse": "This email address is already in use.",
|
||||
"emailRateLimited": "Too many email-address changes. Try again later."
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"reproof": {
|
||||
"required": "Confirm your current password to change this.",
|
||||
"failed": "That did not verify. Nothing was changed."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -440,7 +446,8 @@
|
||||
"errorEmailUnverified": "Your identity provider did not mark your email address as verified.",
|
||||
"errorIdentityConflict": "That email address belongs to an account that is already linked to a different SSO identity.",
|
||||
"errorRegistrationDisabled": "No account matches that identity, and new sign-ups are currently closed.",
|
||||
"errorFailed": "Single sign-on failed. Please try again."
|
||||
"errorFailed": "Single sign-on failed. Please try again.",
|
||||
"errorLinkRequired": "An account with this email address already exists here. Sign in to it once with its password or passkey to connect it to single sign-on."
|
||||
},
|
||||
"native": {
|
||||
"signingInToApp": "Signing in to the HealthLog app"
|
||||
@@ -5553,7 +5560,9 @@
|
||||
"invalidFormat": "That is not written in a form this field can read.",
|
||||
"invalidType": "That value did not arrive in a form this field can store.",
|
||||
"rateLimited": "Changed too often. Wait a little and try again.",
|
||||
"other": "This value was not accepted. Check it and try again."
|
||||
"other": "This value was not accepted. Check it and try again.",
|
||||
"reproofRequired": "Enter your current password to change the email address.",
|
||||
"reproofFailed": "That password is not correct, so the email address was not changed."
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Profile photo",
|
||||
@@ -5594,7 +5603,9 @@
|
||||
"currentPassword": "Current password",
|
||||
"authenticatorCode": "Authenticator code",
|
||||
"authenticatorPlaceholder": "6-digit authenticator code",
|
||||
"verifyAndAdd": "Verify and add"
|
||||
"verifyAndAdd": "Verify and add",
|
||||
"factorDescription": "Verify an existing sign-in method before adding a second factor.",
|
||||
"verifyAndContinue": "Verify and continue"
|
||||
},
|
||||
"passkeyName": "Name",
|
||||
"noPasskeys": "No passkeys registered.",
|
||||
@@ -6727,7 +6738,8 @@
|
||||
"unavailableExplainer": "This server no longer allows document archives, so the connection below cannot be used. You can still remove it.",
|
||||
"moduleOffExplainer": "Documents are switched off, so the connection below is not used. You can still remove it.",
|
||||
"tokenNewAddress": "A new address needs the API token again."
|
||||
}
|
||||
},
|
||||
"emailChangePassword": "Current password (needed to change the email address)"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
|
||||
+16
-4
@@ -128,6 +128,12 @@
|
||||
"emailInUse": "Esta dirección de correo ya está en uso.",
|
||||
"emailRateLimited": "Demasiados cambios de dirección de correo. Inténtalo más tarde."
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"reproof": {
|
||||
"required": "Confirma tu contraseña actual para cambiar esto.",
|
||||
"failed": "La verificación ha fallado. No se ha cambiado nada."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -440,7 +446,8 @@
|
||||
"errorEmailUnverified": "Tu proveedor de identidad no marcó tu correo electrónico como verificado.",
|
||||
"errorIdentityConflict": "Ese correo electrónico pertenece a una cuenta que ya está vinculada a otra identidad SSO.",
|
||||
"errorRegistrationDisabled": "Ninguna cuenta coincide con esa identidad y los nuevos registros están cerrados actualmente.",
|
||||
"errorFailed": "El inicio de sesión único falló. Inténtalo de nuevo."
|
||||
"errorFailed": "El inicio de sesión único falló. Inténtalo de nuevo.",
|
||||
"errorLinkRequired": "Ya existe aquí una cuenta con esta dirección de correo. Inicia sesión en ella una vez con su contraseña o llave de acceso para conectarla al inicio de sesión único."
|
||||
},
|
||||
"native": {
|
||||
"signingInToApp": "Iniciando sesión en la app de HealthLog"
|
||||
@@ -5553,7 +5560,9 @@
|
||||
"invalidFormat": "Eso no está escrito en un formato que este campo pueda leer.",
|
||||
"invalidType": "El valor no llegó en un formato que este campo pueda guardar.",
|
||||
"rateLimited": "Se ha cambiado demasiadas veces. Espera un poco e inténtalo de nuevo.",
|
||||
"other": "No se aceptó este valor. Revísalo e inténtalo de nuevo."
|
||||
"other": "No se aceptó este valor. Revísalo e inténtalo de nuevo.",
|
||||
"reproofRequired": "Introduce tu contraseña actual para cambiar la dirección de correo.",
|
||||
"reproofFailed": "La contraseña no es correcta, así que la dirección de correo no se ha cambiado."
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Foto de perfil",
|
||||
@@ -5594,7 +5603,9 @@
|
||||
"currentPassword": "Contraseña actual",
|
||||
"authenticatorCode": "Código del autenticador",
|
||||
"authenticatorPlaceholder": "Código de seis dígitos del autenticador",
|
||||
"verifyAndAdd": "Verificar y añadir"
|
||||
"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"
|
||||
},
|
||||
"passkeyName": "Nombre",
|
||||
"noPasskeys": "No hay claves de acceso registradas.",
|
||||
@@ -6727,7 +6738,8 @@
|
||||
"unavailableExplainer": "Este servidor ya no permite archivos de documentos, así que la conexión de abajo no se puede usar. Aún puedes eliminarla.",
|
||||
"moduleOffExplainer": "Los documentos están desactivados, así que la conexión de abajo no se usa. Aún puedes eliminarla.",
|
||||
"tokenNewAddress": "Una dirección nueva necesita de nuevo el token de la API."
|
||||
}
|
||||
},
|
||||
"emailChangePassword": "Contraseña actual (necesaria para cambiar la dirección de correo)"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
|
||||
+16
-4
@@ -128,6 +128,12 @@
|
||||
"emailInUse": "Cette adresse e-mail est déjà utilisée.",
|
||||
"emailRateLimited": "Trop de changements d'adresse e-mail. Réessayez plus tard."
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"reproof": {
|
||||
"required": "Confirmez votre mot de passe actuel pour modifier ceci.",
|
||||
"failed": "La vérification a échoué. Rien n'a été modifié."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -440,7 +446,8 @@
|
||||
"errorEmailUnverified": "Votre fournisseur d'identité n'a pas marqué votre adresse e-mail comme vérifiée.",
|
||||
"errorIdentityConflict": "Cette adresse e-mail appartient à un compte déjà lié à une autre identité SSO.",
|
||||
"errorRegistrationDisabled": "Aucun compte ne correspond à cette identité et les nouvelles inscriptions sont actuellement fermées.",
|
||||
"errorFailed": "L'authentification unique a échoué. Veuillez réessayer."
|
||||
"errorFailed": "L'authentification unique a échoué. Veuillez réessayer.",
|
||||
"errorLinkRequired": "Un compte avec cette adresse e-mail existe déjà ici. Connectez-vous-y une fois avec son mot de passe ou sa clé d'accès pour le relier à l'authentification unique."
|
||||
},
|
||||
"native": {
|
||||
"signingInToApp": "Connexion à l'application HealthLog"
|
||||
@@ -5553,7 +5560,9 @@
|
||||
"invalidFormat": "Ce n'est pas écrit dans un format que ce champ peut lire.",
|
||||
"invalidType": "La valeur n'est pas arrivée dans un format que ce champ peut enregistrer.",
|
||||
"rateLimited": "Modifié trop souvent. Patientez un peu et réessayez.",
|
||||
"other": "Cette valeur n'a pas été acceptée. Vérifiez-la et réessayez."
|
||||
"other": "Cette valeur n'a pas été acceptée. Vérifiez-la et réessayez.",
|
||||
"reproofRequired": "Saisissez votre mot de passe actuel pour changer l'adresse e-mail.",
|
||||
"reproofFailed": "Ce mot de passe est incorrect, l'adresse e-mail n'a donc pas été modifiée."
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Photo de profil",
|
||||
@@ -5594,7 +5603,9 @@
|
||||
"currentPassword": "Mot de passe actuel",
|
||||
"authenticatorCode": "Code d’authentification",
|
||||
"authenticatorPlaceholder": "Code d’authentification à 6 chiffres",
|
||||
"verifyAndAdd": "Vérifier et ajouter"
|
||||
"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"
|
||||
},
|
||||
"passkeyName": "Nom",
|
||||
"noPasskeys": "Aucune clé d’accès enregistrée.",
|
||||
@@ -6727,7 +6738,8 @@
|
||||
"unavailableExplainer": "Ce serveur n'autorise plus les archives de documents ; la connexion ci-dessous ne peut donc pas servir. Vous pouvez toujours la supprimer.",
|
||||
"moduleOffExplainer": "Les documents sont désactivés ; la connexion ci-dessous n'est donc pas utilisée. Vous pouvez toujours la supprimer.",
|
||||
"tokenNewAddress": "Une nouvelle adresse demande de saisir à nouveau le jeton d'API."
|
||||
}
|
||||
},
|
||||
"emailChangePassword": "Mot de passe actuel (nécessaire pour changer l'adresse e-mail)"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
|
||||
+16
-4
@@ -128,6 +128,12 @@
|
||||
"emailInUse": "Questo indirizzo email è già in uso.",
|
||||
"emailRateLimited": "Troppi cambi di indirizzo email. Riprova più tardi."
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"reproof": {
|
||||
"required": "Conferma la password attuale per modificarlo.",
|
||||
"failed": "La verifica non è riuscita. Non è stato modificato nulla."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -440,7 +446,8 @@
|
||||
"errorEmailUnverified": "Il tuo provider di identità non ha contrassegnato il tuo indirizzo email come verificato.",
|
||||
"errorIdentityConflict": "Questo indirizzo email appartiene a un account già collegato a un'altra identità SSO.",
|
||||
"errorRegistrationDisabled": "Nessun account corrisponde a questa identità e le nuove registrazioni sono attualmente chiuse.",
|
||||
"errorFailed": "L'accesso Single Sign-On non è riuscito. Riprova."
|
||||
"errorFailed": "L'accesso Single Sign-On non è riuscito. Riprova.",
|
||||
"errorLinkRequired": "Esiste già un account con questo indirizzo email. Accedi una volta con la sua password o passkey per collegarlo al single sign-on."
|
||||
},
|
||||
"native": {
|
||||
"signingInToApp": "Accesso all'app HealthLog"
|
||||
@@ -5553,7 +5560,9 @@
|
||||
"invalidFormat": "Non è scritto in un formato che questo campo possa leggere.",
|
||||
"invalidType": "Il valore non è arrivato in un formato che questo campo possa salvare.",
|
||||
"rateLimited": "Modificato troppe volte. Aspetta un momento e riprova.",
|
||||
"other": "Questo valore non è stato accettato. Controllalo e riprova."
|
||||
"other": "Questo valore non è stato accettato. Controllalo e riprova.",
|
||||
"reproofRequired": "Inserisci la password attuale per cambiare l'indirizzo email.",
|
||||
"reproofFailed": "La password non è corretta, quindi l'indirizzo email non è stato cambiato."
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Foto del profilo",
|
||||
@@ -5594,7 +5603,9 @@
|
||||
"currentPassword": "Password attuale",
|
||||
"authenticatorCode": "Codice dell’app di autenticazione",
|
||||
"authenticatorPlaceholder": "Codice a 6 cifre dell’app di autenticazione",
|
||||
"verifyAndAdd": "Verifica e aggiungi"
|
||||
"verifyAndAdd": "Verifica e aggiungi",
|
||||
"factorDescription": "Verifica un metodo di accesso esistente prima di aggiungere un secondo fattore.",
|
||||
"verifyAndContinue": "Verifica e continua"
|
||||
},
|
||||
"passkeyName": "Nome",
|
||||
"noPasskeys": "Nessuna passkey registrata.",
|
||||
@@ -6727,7 +6738,8 @@
|
||||
"unavailableExplainer": "Questo server non consente più gli archivi di documenti, quindi il collegamento qui sotto non si può usare. Puoi comunque rimuoverlo.",
|
||||
"moduleOffExplainer": "I documenti sono disattivati, quindi il collegamento qui sotto non viene usato. Puoi comunque rimuoverlo.",
|
||||
"tokenNewAddress": "Un nuovo indirizzo richiede di nuovo il token API."
|
||||
}
|
||||
},
|
||||
"emailChangePassword": "Password attuale (necessaria per cambiare l'indirizzo email)"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
|
||||
+16
-4
@@ -128,6 +128,12 @@
|
||||
"emailInUse": "이미 사용 중인 이메일 주소예요.",
|
||||
"emailRateLimited": "이메일 주소를 너무 자주 변경했어요. 잠시 후 다시 시도해 주세요."
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"reproof": {
|
||||
"required": "변경하려면 현재 비밀번호를 확인하세요.",
|
||||
"failed": "확인에 실패했습니다. 아무것도 변경되지 않았습니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -440,7 +446,8 @@
|
||||
"errorEmailUnverified": "제공자가 이메일 주소를 인증됨으로 표시하지 않았어요.",
|
||||
"errorIdentityConflict": "그 이메일 주소는 다른 SSO 계정에 이미 연결돼 있어요.",
|
||||
"errorRegistrationDisabled": "일치하는 계정이 없고, 지금은 회원가입이 닫혀 있어요.",
|
||||
"errorFailed": "싱글 사인온에 실패했어요. 다시 시도해 주세요."
|
||||
"errorFailed": "싱글 사인온에 실패했어요. 다시 시도해 주세요.",
|
||||
"errorLinkRequired": "이 이메일 주소를 사용하는 계정이 이미 있습니다. 싱글 사인온에 연결하려면 해당 계정의 비밀번호나 패스키로 한 번 로그인하세요."
|
||||
},
|
||||
"native": {
|
||||
"signingInToApp": "HealthLog 앱에 로그인하는 중"
|
||||
@@ -5553,7 +5560,9 @@
|
||||
"invalidFormat": "이 항목이 읽을 수 있는 형식이 아니에요.",
|
||||
"invalidType": "이 항목이 저장할 수 있는 형식으로 오지 않았어요.",
|
||||
"rateLimited": "너무 자주 바꿨어요. 잠시 후 다시 시도해 주세요.",
|
||||
"other": "이 값은 저장하지 못했어요. 확인한 뒤 다시 시도해 주세요."
|
||||
"other": "이 값은 저장하지 못했어요. 확인한 뒤 다시 시도해 주세요.",
|
||||
"reproofRequired": "이메일 주소를 변경하려면 현재 비밀번호를 입력하세요.",
|
||||
"reproofFailed": "비밀번호가 올바르지 않아 이메일 주소가 변경되지 않았습니다."
|
||||
},
|
||||
"avatar": {
|
||||
"title": "프로필 사진",
|
||||
@@ -5594,7 +5603,9 @@
|
||||
"currentPassword": "현재 비밀번호",
|
||||
"authenticatorCode": "인증 앱 코드",
|
||||
"authenticatorPlaceholder": "6자리 인증 앱 코드",
|
||||
"verifyAndAdd": "확인 후 추가"
|
||||
"verifyAndAdd": "확인 후 추가",
|
||||
"factorDescription": "두 번째 인증 수단을 추가하기 전에 기존 로그인 방법을 확인하세요.",
|
||||
"verifyAndContinue": "확인하고 계속"
|
||||
},
|
||||
"passkeyName": "이름",
|
||||
"noPasskeys": "등록된 패스키가 없어요.",
|
||||
@@ -6727,7 +6738,8 @@
|
||||
"unavailableExplainer": "이 서버는 더 이상 문서 보관함을 허용하지 않아 아래 연결을 사용할 수 없습니다. 연결은 여전히 삭제할 수 있습니다.",
|
||||
"moduleOffExplainer": "문서 기능이 꺼져 있어 아래 연결은 사용되지 않습니다. 연결은 여전히 삭제할 수 있습니다.",
|
||||
"tokenNewAddress": "새 주소에는 API 토큰을 다시 입력해야 합니다."
|
||||
}
|
||||
},
|
||||
"emailChangePassword": "현재 비밀번호 (이메일 주소를 변경하려면 필요)"
|
||||
},
|
||||
"admin": {
|
||||
"title": "관리자",
|
||||
|
||||
+16
-4
@@ -128,6 +128,12 @@
|
||||
"emailInUse": "Ten adres e-mail jest już używany.",
|
||||
"emailRateLimited": "Zbyt wiele zmian adresu e-mail. Spróbuj ponownie później."
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"reproof": {
|
||||
"required": "Potwierdź obecne hasło, aby to zmienić.",
|
||||
"failed": "Weryfikacja nie powiodła się. Nic nie zostało zmienione."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -440,7 +446,8 @@
|
||||
"errorEmailUnverified": "Twój dostawca tożsamości nie oznaczył Twojego adresu e-mail jako zweryfikowanego.",
|
||||
"errorIdentityConflict": "Ten adres e-mail należy do konta powiązanego już z inną tożsamością SSO.",
|
||||
"errorRegistrationDisabled": "Żadne konto nie pasuje do tej tożsamości, a nowe rejestracje są obecnie zamknięte.",
|
||||
"errorFailed": "Logowanie jednokrotne nie powiodło się. Spróbuj ponownie."
|
||||
"errorFailed": "Logowanie jednokrotne nie powiodło się. Spróbuj ponownie.",
|
||||
"errorLinkRequired": "Konto z tym adresem e-mail już tu istnieje. Zaloguj się do niego raz hasłem lub kluczem dostępu, aby połączyć je z logowaniem jednokrotnym."
|
||||
},
|
||||
"native": {
|
||||
"signingInToApp": "Logowanie do aplikacji HealthLog"
|
||||
@@ -5553,7 +5560,9 @@
|
||||
"invalidFormat": "To nie jest zapisane w formacie, który to pole może odczytać.",
|
||||
"invalidType": "Wartość nie dotarła w formacie, który to pole może zapisać.",
|
||||
"rateLimited": "Zmieniane zbyt często. Odczekaj chwilę i spróbuj ponownie.",
|
||||
"other": "Ta wartość nie została przyjęta. Sprawdź ją i spróbuj ponownie."
|
||||
"other": "Ta wartość nie została przyjęta. Sprawdź ją i spróbuj ponownie.",
|
||||
"reproofRequired": "Wpisz obecne hasło, aby zmienić adres e-mail.",
|
||||
"reproofFailed": "Hasło jest nieprawidłowe, więc adres e-mail nie został zmieniony."
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Zdjęcie profilowe",
|
||||
@@ -5594,7 +5603,9 @@
|
||||
"currentPassword": "Aktualne hasło",
|
||||
"authenticatorCode": "Kod z aplikacji uwierzytelniającej",
|
||||
"authenticatorPlaceholder": "6-cyfrowy kod z aplikacji uwierzytelniającej",
|
||||
"verifyAndAdd": "Zweryfikuj i dodaj"
|
||||
"verifyAndAdd": "Zweryfikuj i dodaj",
|
||||
"factorDescription": "Potwierdź istniejącą metodę logowania, zanim dodasz drugi składnik.",
|
||||
"verifyAndContinue": "Potwierdź i kontynuuj"
|
||||
},
|
||||
"passkeyName": "Nazwa",
|
||||
"noPasskeys": "Brak zarejestrowanych kluczy dostępu.",
|
||||
@@ -6727,7 +6738,8 @@
|
||||
"unavailableExplainer": "Ten serwer nie pozwala już na archiwa dokumentów, więc poniższego połączenia nie da się użyć. Nadal możesz je usunąć.",
|
||||
"moduleOffExplainer": "Dokumenty są wyłączone, więc poniższe połączenie nie jest używane. Nadal możesz je usunąć.",
|
||||
"tokenNewAddress": "Nowy adres wymaga ponownego podania tokenu API."
|
||||
}
|
||||
},
|
||||
"emailChangePassword": "Obecne hasło (potrzebne do zmiany adresu e-mail)"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administracja",
|
||||
|
||||
+11
-10
@@ -295,18 +295,19 @@ const nextConfig: NextConfig = {
|
||||
outputFileTracingExcludes: {
|
||||
"*": ["./next.config.ts"],
|
||||
},
|
||||
// Next 16 caps the request body that passes THROUGH middleware (our
|
||||
// `src/proxy.ts`) at ~10MB by default, silently truncating larger bodies
|
||||
// before the route handler sees them. The Apple Health `export.zip` importer
|
||||
// (`/api/import/apple-health-export`) streams multi-MB archives to disk; the
|
||||
// 10MB cap truncated them so the ZIP end-of-central-directory was lost and
|
||||
// the parser failed (GitHub #281). Raise the ceiling so real exports pass
|
||||
// intact. The value is a ceiling, not a buffer — only the actual upload size
|
||||
// is held — so this is safe; very large exports also want a matching
|
||||
// reverse-proxy body limit (see docs/self-hosting/reverse-proxy.md).
|
||||
// Next reads the body of every non-GET request the proxy (`src/proxy.ts`)
|
||||
// matches into memory, so both the proxy and the route can read it, and it
|
||||
// does so BEFORE the route runs — before any authentication. Everything past
|
||||
// this ceiling is dropped, not refused. It used to be 512 MB so the Apple
|
||||
// Health importer could receive whole archives (GitHub #281), which let any
|
||||
// anonymous POST make the server hold that much. It is 1 MB now: the routes
|
||||
// whose real bodies are larger are left out of the proxy matcher and read
|
||||
// their own body after authenticating (`src/lib/http/proxy-bypass-routes.ts`),
|
||||
// and every other route's own cap is at or below this. Large exports still
|
||||
// want a matching reverse-proxy body limit (docs/self-hosting/reverse-proxy.md).
|
||||
experimental: {
|
||||
optimizePackageImports: ["recharts", "lucide-react"],
|
||||
middlewareClientMaxBodySize: "512mb",
|
||||
proxyClientMaxBodySize: "1mb",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* The large-upload routes are left out of the proxy matcher, so `apiHandler`
|
||||
* performs the proxy's duties for them: the worker-only and demo-mode
|
||||
* refusals, the transport security headers and a CSP. Every other route still
|
||||
* gets those from the proxy and is left untouched here.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const runWeb = vi.hoisted(() => ({ value: true }));
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
shouldRunWeb: () => runWeb.value,
|
||||
shouldRunWorker: () => true,
|
||||
getProcessType: () => "all",
|
||||
}));
|
||||
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
runWeb.value = true;
|
||||
});
|
||||
|
||||
function call(path: string, method = "POST") {
|
||||
const handler = vi.fn(async (_req: NextRequest) =>
|
||||
NextResponse.json({ data: "ok" }),
|
||||
);
|
||||
const wrapped = apiHandler(handler);
|
||||
return {
|
||||
handler,
|
||||
res: wrapped(new NextRequest(`http://localhost${path}`, { method })),
|
||||
};
|
||||
}
|
||||
|
||||
describe("apiHandler on a route the proxy does not see", () => {
|
||||
it("adds the proxy's security headers and a CSP", async () => {
|
||||
const { res } = call("/api/measurements/batch");
|
||||
const r = await res;
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(r.headers.get("x-frame-options")).toBe("DENY");
|
||||
expect(r.headers.get("content-security-policy")).toContain(
|
||||
"default-src 'none'",
|
||||
);
|
||||
expect(r.headers.get("x-request-id")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("refuses a mutation on a demo instance before the handler runs", async () => {
|
||||
vi.stubEnv("DEMO_MODE", "true");
|
||||
const { res, handler } = call("/api/import/csv");
|
||||
const r = await res;
|
||||
expect(r.status).toBe(403);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(r.headers.get("x-frame-options")).toBe("DENY");
|
||||
});
|
||||
|
||||
it("refuses in a worker-only container", async () => {
|
||||
runWeb.value = false;
|
||||
const { res, handler } = call("/api/admin/backups/upload");
|
||||
expect((await res).status).toBe(503);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves a proxied route to the proxy", async () => {
|
||||
vi.stubEnv("DEMO_MODE", "true");
|
||||
const { res, handler } = call("/api/auth/me/timezone", "PUT");
|
||||
const r = await res;
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(r.headers.get("content-security-policy")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ vi.mock("@/lib/auth/login-response", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/auth/mfa/challenge", () => ({
|
||||
loadActiveChallenge: vi.fn(),
|
||||
reserveChallengeAttempt: vi.fn().mockResolvedValue(true),
|
||||
recordChallengeFailure: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ exhausted: false, attempts: 1 }),
|
||||
@@ -53,6 +54,7 @@ import { prisma } from "@/lib/db";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import {
|
||||
loadActiveChallenge,
|
||||
reserveChallengeAttempt,
|
||||
recordChallengeFailure,
|
||||
claimChallenge,
|
||||
} from "@/lib/auth/mfa/challenge";
|
||||
@@ -105,6 +107,16 @@ describe("POST /api/auth/mfa/verify", () => {
|
||||
expect(arg.source).toBe("mfa.verify");
|
||||
});
|
||||
|
||||
it("no attempt left to reserve → 401 before the factor is evaluated", async () => {
|
||||
vi.mocked(loadActiveChallenge).mockResolvedValue(ACTIVE_CHALLENGE as never);
|
||||
vi.mocked(reserveChallengeAttempt).mockResolvedValueOnce(false);
|
||||
|
||||
const res = await POST(verifyRequest(GOOD_BODY));
|
||||
expect(res.status).toBe(401);
|
||||
expect(verifyMfaFactor).not.toHaveBeenCalled();
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("invalid ticket → 401, no session", async () => {
|
||||
vi.mocked(loadActiveChallenge).mockResolvedValue(null);
|
||||
const res = await POST(verifyRequest(GOOD_BODY));
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Every route that checks a password or a second factor is a guessing oracle,
|
||||
* and every one must be throttled and must record a refused guess.
|
||||
*
|
||||
* The passkey-enrollment re-proof verified a password and a TOTP code with
|
||||
* neither: a stolen session could guess there as fast as it could send
|
||||
* requests, and nothing in the audit log would show it. Behavioural tests pin
|
||||
* each route that was fixed; this freezes the SET, so a new route that starts
|
||||
* verifying a credential has to be listed here, and the listing asks the two
|
||||
* questions at the moment it is added.
|
||||
*
|
||||
* Matching is on the verifier's name anywhere in the file, not on an import
|
||||
* shape, so an aliased import still counts. It remains a tripwire: it proves a
|
||||
* throttle and an audit call are present in the file, not that they sit on the
|
||||
* right branch — the per-route tests do that.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const APP = join(process.cwd(), "src", "app");
|
||||
|
||||
const VERIFIER =
|
||||
/\b(verifyPassword|verifyPasswordOrDummy|verifyMfaFactor|verifyTotp|verifyMfaAuthentication|verifyAuthentication|verifyExistingFactorProof|checkCookieEnrollmentProof)\b/;
|
||||
|
||||
const THROTTLE =
|
||||
/\b(checkRateLimit|checkAuthSurfaceRateLimit|throttleReproof|checkCookieEnrollmentProof|reserveChallengeAttempt)\s*\(/;
|
||||
|
||||
const AUDIT =
|
||||
/\b(auditLog|recordReproofFailure|checkCookieEnrollmentProof)\s*\(/;
|
||||
|
||||
/**
|
||||
* Every file under src/app that verifies a credential. Adding one means
|
||||
* answering, in its own tests, how a wrong guess is limited and recorded.
|
||||
*/
|
||||
const EXPECTED = [
|
||||
"api/auth/login/route.ts",
|
||||
"api/auth/me/mfa/disable/route.ts",
|
||||
"api/auth/me/mfa/totp/confirm/route.ts",
|
||||
"api/auth/me/mfa/totp/setup/route.ts",
|
||||
"api/auth/me/mfa/webauthn/register/options/route.ts",
|
||||
"api/auth/mfa/verify/route.ts",
|
||||
"api/auth/mfa/webauthn/verify/route.ts",
|
||||
"api/auth/passkey/login-verify/route.ts",
|
||||
"api/auth/passkey/register-options/route.ts",
|
||||
"api/auth/password/route.ts",
|
||||
"api/auth/step-up/route.ts",
|
||||
];
|
||||
|
||||
function verifyingFiles(): string[] {
|
||||
return walkSourceFiles(APP, { floor: 500 })
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !/\.test\.tsx?$/.test(p))
|
||||
.filter((p) => VERIFIER.test(readFileSync(join(APP, p), "utf8")));
|
||||
}
|
||||
|
||||
describe("credential-verifying routes", () => {
|
||||
const found = verifyingFiles();
|
||||
|
||||
it("finds the verifying routes at all", () => {
|
||||
// An empty match agrees with nothing and would pass on a tree never read.
|
||||
expect(found.length).toBeGreaterThanOrEqual(EXPECTED.length);
|
||||
});
|
||||
|
||||
it("is exactly the listed set", () => {
|
||||
expect(found).toEqual(EXPECTED);
|
||||
});
|
||||
|
||||
it.each(EXPECTED)("%s is throttled and audits refusals", (rel) => {
|
||||
const src = readFileSync(join(APP, rel), "utf8");
|
||||
expect(src).toMatch(THROTTLE);
|
||||
expect(src).toMatch(AUDIT);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* The proxy's body ceiling, and the routes that stay out of the proxy so their
|
||||
* larger bodies are never read before authentication.
|
||||
*
|
||||
* Next reads the body of every non-GET request the proxy matcher covers into
|
||||
* memory before the route runs. At 512 MB that let any anonymous POST make the
|
||||
* server hold half a gigabyte. The ceiling is 1 MB; the routes whose bodies are
|
||||
* legitimately larger are excluded from the matcher and bound their own body
|
||||
* after the caller is known. Three things have to stay true together, and this
|
||||
* pins each:
|
||||
*
|
||||
* 1. the ceiling in next.config.ts is 1 MB;
|
||||
* 2. the matcher, compiled exactly as Next compiles it, skips precisely the
|
||||
* bypass list and still covers every other API route (and the tesseract
|
||||
* worker, which needs its CSP);
|
||||
* 3. every route that reads a body larger than the ceiling is on the list.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
import {
|
||||
PROXY_BYPASS_ROUTES,
|
||||
isProxyBypassRoute,
|
||||
} from "@/lib/http/proxy-bypass-routes";
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({ shouldRunWeb: () => true }));
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const ONE_MB = 1024 * 1024;
|
||||
|
||||
async function compiledMatcher(): Promise<RegExp> {
|
||||
const { config } = await import("../proxy");
|
||||
// Next's own compiler for `config.matcher`, so the test sees the regex the
|
||||
// server will actually run. Not part of Next's typed surface.
|
||||
const analysis =
|
||||
(await import("next/dist/build/analysis/get-page-static-info.js")) as unknown as {
|
||||
getMiddlewareMatchers: (
|
||||
m: unknown,
|
||||
c: Record<string, unknown>,
|
||||
) => Array<{ regexp: string }>;
|
||||
};
|
||||
const { getMiddlewareMatchers } = analysis;
|
||||
const [m] = getMiddlewareMatchers(config.matcher, {});
|
||||
return new RegExp(m.regexp);
|
||||
}
|
||||
|
||||
/** Every API route path, with dynamic segments filled in. */
|
||||
function apiRoutePaths(): string[] {
|
||||
const appDir = join(ROOT, "src", "app");
|
||||
return walkSourceFiles(appDir, { floor: 500 })
|
||||
.filter((p) => p.startsWith("api/") && p.endsWith("/route.ts"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.map((p) =>
|
||||
(
|
||||
"/" + p.replace(/\/route\.ts$/, "").replace(/\[\.{0,3}[^\]]+\]/g, "x")
|
||||
).replace(/\/\([^)]+\)/g, ""),
|
||||
)
|
||||
.sort();
|
||||
}
|
||||
|
||||
describe("proxy body ceiling", () => {
|
||||
it("is 1 MB, under the current option name", () => {
|
||||
const src = readFileSync(join(ROOT, "next.config.ts"), "utf8");
|
||||
expect(src).toMatch(/proxyClientMaxBodySize:\s*"1mb"/);
|
||||
expect(src).not.toMatch(/middlewareClientMaxBodySize/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("proxy matcher", () => {
|
||||
it("skips exactly the bypass routes and covers every other API route", async () => {
|
||||
const re = await compiledMatcher();
|
||||
const routes = apiRoutePaths();
|
||||
expect(routes.length).toBeGreaterThan(300);
|
||||
|
||||
for (const path of PROXY_BYPASS_ROUTES) {
|
||||
expect(routes, `${path} is not a route`).toContain(path);
|
||||
expect(re.test(path), `${path} still passes the proxy`).toBe(false);
|
||||
expect(re.test(`${path}/`), `${path}/ still passes the proxy`).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
const covered = routes.filter((p) => !isProxyBypassRoute(p));
|
||||
const escaped = covered.filter((p) => !re.test(p));
|
||||
expect(escaped).toEqual([]);
|
||||
});
|
||||
|
||||
it("still covers pages, the tesseract worker and sub-paths of bypass routes", async () => {
|
||||
const re = await compiledMatcher();
|
||||
for (const path of [
|
||||
"/",
|
||||
"/dashboard",
|
||||
"/labs",
|
||||
"/documents",
|
||||
"/tesseract/worker.min.js",
|
||||
"/api/auth/login",
|
||||
"/api/user/avatar/abc",
|
||||
"/api/documents/inbound/abc/extract",
|
||||
"/api/import/csv/preview",
|
||||
]) {
|
||||
expect(re.test(path), path).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** Byte caps a route file declares, from `maxBytes:` literals and `*_BYTES` constants. */
|
||||
function declaredCaps(src: string): number[] {
|
||||
const consts = new Map<string, number>();
|
||||
for (const m of src.matchAll(
|
||||
/const\s+([A-Z0-9_]*BYTES)\s*=\s*([\d.\s*_]+);/g,
|
||||
)) {
|
||||
const value = Function(`return (${m[2].replace(/_/g, "")})`)() as number;
|
||||
consts.set(m[1], value);
|
||||
}
|
||||
const caps: number[] = [...consts.values()];
|
||||
for (const m of src.matchAll(/maxBytes:\s*([\d\s*_]+)[,\s}]/g)) {
|
||||
caps.push(Function(`return (${m[1].replace(/_/g, "")})`)() as number);
|
||||
}
|
||||
return caps;
|
||||
}
|
||||
|
||||
describe("the bypass list is complete", () => {
|
||||
const appDir = join(ROOT, "src", "app");
|
||||
const files = walkSourceFiles(appDir, { floor: 500 }).filter(
|
||||
(p) =>
|
||||
p.startsWith("api/") &&
|
||||
p.endsWith("/route.ts") &&
|
||||
!p.includes("__tests__"),
|
||||
);
|
||||
const pathOf = (rel: string) =>
|
||||
"/" + rel.replace(/\/route\.ts$/, "").replace(/\[\.{0,3}[^\]]+\]/g, "x");
|
||||
|
||||
it("lists every route whose declared body cap exceeds the ceiling", () => {
|
||||
const over = files
|
||||
.filter((rel) =>
|
||||
declaredCaps(readFileSync(join(appDir, rel), "utf8")).some(
|
||||
(n) => n > ONE_MB,
|
||||
),
|
||||
)
|
||||
.map(pathOf);
|
||||
expect(over.length).toBeGreaterThan(5);
|
||||
expect(over.filter((p) => !isProxyBypassRoute(p))).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists every route that streams or multipart-parses its body", () => {
|
||||
// The OAuth forms are tiny url-encoded bodies with their own 16 KB cap.
|
||||
const SMALL_FORMS = new Set([
|
||||
"/api/mcp/oauth/token",
|
||||
"/api/mcp/oauth/register",
|
||||
"/api/mcp/oauth/authorize",
|
||||
]);
|
||||
const streaming = files
|
||||
.filter((rel) =>
|
||||
/readBoundedBody\(|Readable\.fromWeb\(|\.formData\(\)|request\.body\b/.test(
|
||||
readFileSync(join(appDir, rel), "utf8"),
|
||||
),
|
||||
)
|
||||
.map(pathOf)
|
||||
.filter((p) => !SMALL_FORMS.has(p));
|
||||
expect(streaming.length).toBeGreaterThan(3);
|
||||
expect(streaming.filter((p) => !isProxyBypassRoute(p))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* Local lab OCR runs tesseract's WebAssembly engine in a same-origin worker
|
||||
* served from `/tesseract/`. Compiling WebAssembly needs `'wasm-unsafe-eval'`
|
||||
* in `script-src`, and a worker takes its CSP from its own script's response.
|
||||
* The grant is scoped the way the AI-host and Withings carve-outs are: the
|
||||
* worker files and the two pages that start OCR, and nothing else.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
shouldRunWeb: () => true,
|
||||
}));
|
||||
|
||||
import { proxy } from "../proxy";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
function scriptSrc(pathname: string): string {
|
||||
const res = proxy(
|
||||
new NextRequest(`http://localhost${pathname}`, {
|
||||
headers: { cookie: "healthlog_session=sess-1" },
|
||||
}),
|
||||
);
|
||||
const csp = res.headers.get("content-security-policy") ?? "";
|
||||
return csp.split(";").find((d) => d.trim().startsWith("script-src")) ?? "";
|
||||
}
|
||||
|
||||
describe("'wasm-unsafe-eval' for local OCR", () => {
|
||||
it.each([
|
||||
"/tesseract/worker.min.js",
|
||||
"/tesseract/tesseract-core-simd-lstm.wasm.js",
|
||||
"/labs",
|
||||
"/documents",
|
||||
])("is granted on %s", (path) => {
|
||||
expect(scriptSrc(path)).toContain("'wasm-unsafe-eval'");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/dashboard",
|
||||
"/",
|
||||
"/labs/some-biomarker",
|
||||
"/settings/labs",
|
||||
"/settings/ai",
|
||||
"/api/labs/ocr/commit",
|
||||
"/tesseractish",
|
||||
])("is not granted on %s", (path) => {
|
||||
const src = scriptSrc(path);
|
||||
expect(src).toContain("script-src");
|
||||
expect(src).not.toContain("wasm-unsafe-eval");
|
||||
});
|
||||
|
||||
it("never widens script-src to plain eval", () => {
|
||||
expect(scriptSrc("/tesseract/worker.min.js")).not.toContain(
|
||||
"'unsafe-eval'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@
|
||||
import { Readable } from "node:stream";
|
||||
import { createGunzip } from "node:zlib";
|
||||
|
||||
import { BodyTooLargeError, readBoundedBody } from "@/lib/labs/ocr-upload";
|
||||
import { NextRequest } from "next/server";
|
||||
import { ZodError } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -64,11 +65,13 @@ export const dynamic = "force-dynamic";
|
||||
* is sent as the raw request body, which is read as a stream.
|
||||
*/
|
||||
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
||||
/** Room for the multipart boundaries and part headers around the file. */
|
||||
const MULTIPART_OVERHEAD_BYTES = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Cap on a file sent as the raw request body, compressed or not. The same
|
||||
* ceiling the app sets for any request body (`middlewareClientMaxBodySize` in
|
||||
* `next.config.ts`), beyond which the body would arrive truncated. A
|
||||
* Cap on a file sent as the raw request body, compressed or not, counted
|
||||
* while the body streams. The route is left out of the proxy matcher
|
||||
* (`src/lib/http/proxy-bypass-routes.ts`), so this is the only ceiling. A
|
||||
* disaster-recovery file of 1.25 million measurements is 662 MB as plain
|
||||
* JSON and 64 MB compressed, so large files go up compressed.
|
||||
*/
|
||||
@@ -179,10 +182,23 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
let source: AsyncIterable<Uint8Array>;
|
||||
if (multipart) {
|
||||
// The multipart parser holds the whole form and counts nothing, so the
|
||||
// bytes are counted first and the form is parsed from what was kept. The
|
||||
// route is outside the proxy matcher, so nothing else bounds this read.
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await request.formData();
|
||||
const bytes = await readBoundedBody(
|
||||
request.body,
|
||||
MAX_UPLOAD_BYTES + MULTIPART_OVERHEAD_BYTES,
|
||||
);
|
||||
formData = await new Response(new Blob([bytes]), {
|
||||
headers: { "content-type": contentType },
|
||||
}).formData();
|
||||
} catch (err) {
|
||||
if (err instanceof BodyTooLargeError) {
|
||||
await denied("stream_size_exceeded");
|
||||
return apiError("Upload exceeds 10 MB limit", 413);
|
||||
}
|
||||
return apiError(
|
||||
`Invalid multipart body: ${err instanceof Error ? err.message : "unknown"}`,
|
||||
400,
|
||||
|
||||
@@ -19,12 +19,19 @@ import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
|
||||
import { consumeTrustedDevice } from "@/lib/auth/trusted-device";
|
||||
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
import { readPendingLink } from "@/lib/auth/oidc-pending-link";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// OIDC_ONLY means password login must be a dead end, not just a hidden
|
||||
// button — otherwise a pre-existing password (or a leaked/reused one)
|
||||
// stays a live bypass of the operator's SSO-only policy.
|
||||
if (isOidcOnly()) {
|
||||
//
|
||||
// One exception, and only for one account: a browser the SSO callback sent
|
||||
// here to confirm an existing account carries a sealed pending link naming
|
||||
// that account. It may prove that account's password (checked again once
|
||||
// the account is resolved) so the link can be made; nothing else opens.
|
||||
const pendingLink = isOidcOnly() ? await readPendingLink() : null;
|
||||
if (isOidcOnly() && !pendingLink) {
|
||||
return apiError("Password login is disabled. Sign in with SSO.", 403, {
|
||||
errorCode: "oidc_only",
|
||||
});
|
||||
@@ -119,6 +126,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Invalid credentials", 401);
|
||||
}
|
||||
|
||||
if (pendingLink && pendingLink.userId !== user.id) {
|
||||
return apiError("Password login is disabled. Sign in with SSO.", 403, {
|
||||
errorCode: "oidc_only",
|
||||
});
|
||||
}
|
||||
|
||||
const ua = request.headers.get("user-agent");
|
||||
|
||||
await auditLog("auth.login.password", {
|
||||
|
||||
@@ -10,7 +10,14 @@
|
||||
*
|
||||
* Gated by `requireMfaManagementAuth`: a cookie session, or a Bearer token
|
||||
* presenting a single-use step-up elevation minted against a re-proved factor.
|
||||
* A token on its own can still never enrol MFA. The recovery-code batch is
|
||||
* A token on its own can still never enrol MFA.
|
||||
*
|
||||
* A cookie session on its own cannot either. Enrolling hands the caller a
|
||||
* factor that later satisfies every step-up gate, so a stolen session must not
|
||||
* be able to mint one: the cookie arm also passes `checkCookieEnrollmentProof`
|
||||
* — a sign-in or second factor inside the last five minutes, or a password /
|
||||
* factor proof in the body (the step-up mint's shapes). The Bearer arm already
|
||||
* carries that proof in its elevation. The recovery-code batch is
|
||||
* issued at `/confirm` (after the factor is proven), not here, so an abandoned
|
||||
* setup never persists codes.
|
||||
*/
|
||||
@@ -26,6 +33,7 @@ import { prisma } from "@/lib/db";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { generateTotpSecret, buildOtpauthUri } from "@/lib/auth/mfa/totp";
|
||||
import { checkCookieEnrollmentProof } from "@/lib/auth/existing-factor-proof";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -57,6 +65,17 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
throw new HttpError(409, "A second factor is already active");
|
||||
}
|
||||
|
||||
if (auth.transport === "cookie") {
|
||||
const refusal = await checkCookieEnrollmentProof({
|
||||
user,
|
||||
sessionId: auth.session.id,
|
||||
request: req,
|
||||
ipAddress: getClientIp(req),
|
||||
stage: "totp_enroll",
|
||||
});
|
||||
if (refusal) return refusal;
|
||||
}
|
||||
|
||||
// Rate limit and the already-active check have passed; the write is next.
|
||||
await auth.commitElevation();
|
||||
|
||||
|
||||
@@ -31,11 +31,16 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/existing-factor-proof", () => ({
|
||||
checkCookieEnrollmentProof: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST as REGISTER_OPTIONS } from "../options/route";
|
||||
import { POST as REGISTER_VERIFY } from "../verify/route";
|
||||
import { requireMfaManagementAuth } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { checkCookieEnrollmentProof } from "@/lib/auth/existing-factor-proof";
|
||||
import {
|
||||
createMfaRegistrationOptions,
|
||||
verifyMfaRegistration,
|
||||
@@ -63,8 +68,16 @@ beforeEach(() => {
|
||||
commitElevation: vi.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(checkCookieEnrollmentProof).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
function optionsRequest(): NextRequest {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/auth/me/mfa/webauthn/register/options",
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
describe("POST /api/auth/me/mfa/webauthn/register/options", () => {
|
||||
it("returns ceremony options + challenge id", async () => {
|
||||
vi.mocked(createMfaRegistrationOptions).mockResolvedValue({
|
||||
@@ -72,7 +85,7 @@ describe("POST /api/auth/me/mfa/webauthn/register/options", () => {
|
||||
challengeId: "ch-1",
|
||||
} as never);
|
||||
|
||||
const res = await REGISTER_OPTIONS();
|
||||
const res = await REGISTER_OPTIONS(optionsRequest());
|
||||
const body = (await res.json()) as {
|
||||
data: { options: unknown; challengeId: string };
|
||||
};
|
||||
@@ -86,6 +99,40 @@ describe("POST /api/auth/me/mfa/webauthn/register/options", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("security-key enrollment needs a fresh proof on the cookie arm", () => {
|
||||
it("stops before any challenge exists when the session cannot show one", async () => {
|
||||
vi.mocked(checkCookieEnrollmentProof).mockResolvedValue(
|
||||
new Response(null, { status: 401 }),
|
||||
);
|
||||
const res = await REGISTER_OPTIONS(optionsRequest());
|
||||
expect(res.status).toBe(401);
|
||||
expect(createMfaRegistrationOptions).not.toHaveBeenCalled();
|
||||
expect(checkCookieEnrollmentProof).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "sess-1",
|
||||
stage: "security_key_enroll",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the Bearer arm to its elevation", async () => {
|
||||
vi.mocked(requireMfaManagementAuth).mockResolvedValue({
|
||||
transport: "bearer",
|
||||
user: USER,
|
||||
apiTokenId: "tok-1",
|
||||
accessTokenHash: "h",
|
||||
commitElevation: vi.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
vi.mocked(createMfaRegistrationOptions).mockResolvedValue({
|
||||
options: {},
|
||||
challengeId: "ch-2",
|
||||
} as never);
|
||||
const res = await REGISTER_OPTIONS(optionsRequest());
|
||||
expect(res.status).toBe(200);
|
||||
expect(checkCookieEnrollmentProof).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/me/mfa/webauthn/register/verify", () => {
|
||||
const credential = {
|
||||
id: "cred-1",
|
||||
|
||||
@@ -5,16 +5,21 @@
|
||||
* session or a Bearer token presenting a single-use step-up elevation; a token
|
||||
* on its own can never enrol MFA. Returns the SimpleWebAuthn creation options +
|
||||
* the server-issued challenge id to present back at /register/verify.
|
||||
*
|
||||
* The cookie arm additionally passes `checkCookieEnrollmentProof`: a session
|
||||
* alone cannot add a security key, for the same reason it cannot enroll TOTP —
|
||||
* the new key would satisfy every step-up gate from then on.
|
||||
*/
|
||||
import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { checkCookieEnrollmentProof } from "@/lib/auth/existing-factor-proof";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { createMfaRegistrationOptions } from "@/lib/auth/mfa/webauthn";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const auth = await requireMfaManagementAuth();
|
||||
const { user } = auth;
|
||||
|
||||
@@ -31,6 +36,17 @@ export const POST = apiHandler(async () => {
|
||||
return res;
|
||||
}
|
||||
|
||||
if (auth.transport === "cookie") {
|
||||
const refusal = await checkCookieEnrollmentProof({
|
||||
user,
|
||||
sessionId: auth.session.id,
|
||||
request: req,
|
||||
ipAddress: getClientIp(req),
|
||||
stage: "security_key_enroll",
|
||||
});
|
||||
if (refusal) return refusal;
|
||||
}
|
||||
|
||||
await auth.commitElevation();
|
||||
|
||||
const { options, challengeId } = await createMfaRegistrationOptions(
|
||||
|
||||
@@ -32,6 +32,7 @@ import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import {
|
||||
loadActiveChallenge,
|
||||
reserveChallengeAttempt,
|
||||
recordChallengeFailure,
|
||||
claimChallenge,
|
||||
} from "@/lib/auth/mfa/challenge";
|
||||
@@ -90,6 +91,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Invalid or expired challenge", 401);
|
||||
}
|
||||
|
||||
// Count this attempt before anything is verified. The reservation is one
|
||||
// guarded UPDATE, so concurrent guesses cannot all slip under the cap the
|
||||
// way a read-then-increment let them.
|
||||
if (!(await reserveChallengeAttempt(challenge.id))) {
|
||||
annotate({ action: { name: "auth.mfa.verify.attempts_exhausted" } });
|
||||
return apiError("Invalid or expired challenge", 401);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: challenge.userId },
|
||||
select: {
|
||||
|
||||
@@ -25,6 +25,7 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
|
||||
vi.mock("@/lib/auth/mfa/challenge", () => ({
|
||||
loadActiveChallenge: vi.fn(),
|
||||
reserveChallengeAttempt: vi.fn().mockResolvedValue(true),
|
||||
recordChallengeFailure: vi.fn().mockResolvedValue({ exhausted: false }),
|
||||
claimChallenge: vi.fn(),
|
||||
}));
|
||||
@@ -50,6 +51,7 @@ import { prisma } from "@/lib/db";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
loadActiveChallenge,
|
||||
reserveChallengeAttempt,
|
||||
claimChallenge,
|
||||
recordChallengeFailure,
|
||||
} from "@/lib/auth/mfa/challenge";
|
||||
@@ -93,6 +95,7 @@ beforeEach(() => {
|
||||
username: "u",
|
||||
onboardingCompletedAt: new Date(),
|
||||
} as never);
|
||||
vi.mocked(reserveChallengeAttempt).mockResolvedValue(true);
|
||||
vi.mocked(recordChallengeFailure).mockResolvedValue({
|
||||
exhausted: false,
|
||||
attempts: 1,
|
||||
@@ -126,6 +129,14 @@ describe("POST /api/auth/mfa/webauthn/verify", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses before the assertion runs when no attempt can be reserved", async () => {
|
||||
vi.mocked(reserveChallengeAttempt).mockResolvedValue(false);
|
||||
const res = await POST(makeRequest(VALID_BODY));
|
||||
expect(res.status).toBe(401);
|
||||
expect(verifyMfaAuthentication).not.toHaveBeenCalled();
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects with 401 and burns an attempt when the assertion fails", async () => {
|
||||
vi.mocked(verifyMfaAuthentication).mockResolvedValue(false as never);
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import {
|
||||
loadActiveChallenge,
|
||||
reserveChallengeAttempt,
|
||||
recordChallengeFailure,
|
||||
claimChallenge,
|
||||
} from "@/lib/auth/mfa/challenge";
|
||||
@@ -86,6 +87,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Invalid or expired challenge", 401);
|
||||
}
|
||||
|
||||
// Count this attempt before anything is verified. The reservation is one
|
||||
// guarded UPDATE, so concurrent guesses cannot all slip under the cap the
|
||||
// way a read-then-increment let them.
|
||||
if (!(await reserveChallengeAttempt(challenge.id))) {
|
||||
annotate({ action: { name: "auth.mfa.verify.attempts_exhausted" } });
|
||||
return apiError("Invalid or expired challenge", 401);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: challenge.userId },
|
||||
select: { id: true, username: true, onboardingCompletedAt: true },
|
||||
|
||||
@@ -16,6 +16,7 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((s: string) => s.replace(/^enc\(/, "").replace(/\)$/, "")),
|
||||
encrypt: vi.fn((s: string) => `enc(${s})`),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -321,26 +322,34 @@ describe("GET /api/auth/oidc/callback", () => {
|
||||
expect(res.status).toBe(307);
|
||||
});
|
||||
|
||||
it("links an existing email-matched account ONCE, stamping (issuer, sub)", async () => {
|
||||
// Inverted in v1.39.3. This used to pin the auto-link: an existing account
|
||||
// with the same (unverified) local email was bound on the spot and signed
|
||||
// in. Whoever set that address first owned the account the SSO user landed
|
||||
// in, so the callback now links nothing and signs nobody in; it leaves a
|
||||
// sealed pending link and sends the browser to confirm the account with its
|
||||
// own credential (`oidc-pending-link.test.ts` covers the completion).
|
||||
it("does not link an email-matched account on the email alone", async () => {
|
||||
mockIdentity();
|
||||
mockUserLookups({ byIdentity: null, byEmail: userRow() });
|
||||
vi.mocked(prisma.user.update).mockResolvedValue(
|
||||
userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }) as never,
|
||||
);
|
||||
|
||||
const res = await GET(validRequest("/dashboard"));
|
||||
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { oidcIssuer: METADATA.issuer, oidcSub: "sub-1" },
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
expect(auditLog).not.toHaveBeenCalledWith(
|
||||
"auth.oidc.linked",
|
||||
expect.anything(),
|
||||
);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.oidc.link_pending",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
expect(createSession).toHaveBeenCalled();
|
||||
expect(res.headers.get("location")).toContain("/dashboard");
|
||||
expect(res.headers.get("location")).toContain("error=oidc_link_required");
|
||||
const setCookie = res.headers.get("set-cookie") ?? "";
|
||||
expect(setCookie).toContain("hl_oidc_link=");
|
||||
expect(setCookie.toLowerCase()).toContain("httponly");
|
||||
expect(setCookie).toContain("Path=/api/auth");
|
||||
});
|
||||
|
||||
it("rejects a login whose email matches an account bound to a DIFFERENT identity", async () => {
|
||||
@@ -505,10 +514,9 @@ describe("GET /api/auth/oidc/callback", () => {
|
||||
|
||||
it("never stamps the session step-up fresh — the 5th createSession arg is null", async () => {
|
||||
mockIdentity();
|
||||
mockUserLookups({ byIdentity: null, byEmail: userRow() });
|
||||
vi.mocked(prisma.user.update).mockResolvedValue(
|
||||
userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }) as never,
|
||||
);
|
||||
mockUserLookups({
|
||||
byIdentity: userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }),
|
||||
});
|
||||
|
||||
await GET(validRequest());
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
buildNativeCallbackUrl,
|
||||
mintNativeHandoff,
|
||||
} from "@/lib/auth/oidc-native-handoff";
|
||||
import { setPendingLinkCookie } from "@/lib/auth/oidc-pending-link";
|
||||
|
||||
const LOGIN_ERROR_URL = "/auth/login";
|
||||
|
||||
@@ -340,14 +341,29 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
annotate({ meta: { reason: "identity_conflict" } });
|
||||
return failRedirect("oidc_identity_conflict");
|
||||
}
|
||||
user = await prisma.user.update({
|
||||
where: { id: byEmail.id },
|
||||
data: { oidcIssuer: metadata.issuer, oidcSub: identity.sub },
|
||||
});
|
||||
await auditLog("auth.oidc.linked", {
|
||||
userId: user.id,
|
||||
// (2b) Never link on the email alone. The local address was never
|
||||
// verified — anyone can register with it or type it into a profile —
|
||||
// so matching it proves nothing about who owns the account. Record
|
||||
// the link this login would make and send the browser to sign in to
|
||||
// the account with its own credential; the link is made there
|
||||
// (`completePendingOidcLink`), and only for this account.
|
||||
await auditLog("auth.oidc.link_pending", {
|
||||
userId: byEmail.id,
|
||||
ipAddress: ip,
|
||||
});
|
||||
annotate({ meta: { reason: "link_confirmation_required" } });
|
||||
if (isNative) {
|
||||
// The native sign-in sheet cannot run the confirmation; the person
|
||||
// signs in on the web once, after which the app's SSO works.
|
||||
return failRedirect("oidc_link_required");
|
||||
}
|
||||
const response = errorRedirect("oidc_link_required");
|
||||
setPendingLinkCookie(response, {
|
||||
userId: byEmail.id,
|
||||
issuer: metadata.issuer,
|
||||
sub: identity.sub,
|
||||
});
|
||||
return response;
|
||||
} else {
|
||||
// (3) Provision.
|
||||
const userCount = await prisma.user.count();
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@/lib/auth/native-client";
|
||||
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
import { completePendingOidcLink } from "@/lib/auth/oidc-pending-link";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// OIDC_ONLY must block passkey login too, not just password login — a
|
||||
@@ -87,6 +88,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// meets an MFA-enforcement policy AND passes `requireFreshMfa` step-up,
|
||||
// exactly like a completed password+TOTP login. (The password+TOTP path is
|
||||
// unchanged — it stamps the session at `/api/auth/mfa/verify`.)
|
||||
// The passkey proved this account's own credential, so a single sign-on
|
||||
// identity this browser was sent here to confirm may now be linked to it.
|
||||
await completePendingOidcLink(user.id, ip ?? null);
|
||||
|
||||
const ua = request.headers.get("user-agent");
|
||||
await createSession(
|
||||
user.id,
|
||||
|
||||
@@ -2,16 +2,20 @@ import { NextRequest } from "next/server";
|
||||
import {
|
||||
createAuthenticationOptions,
|
||||
createRegistrationOptions,
|
||||
verifyAuthentication,
|
||||
} from "@/lib/auth/passkey";
|
||||
import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
|
||||
import {
|
||||
createMfaAuthenticationOptions,
|
||||
verifyMfaAuthentication,
|
||||
} from "@/lib/auth/mfa/webauthn";
|
||||
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
recordReproofFailure,
|
||||
throttleReproof,
|
||||
verifyExistingFactorProof,
|
||||
} from "@/lib/auth/existing-factor-proof";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { stepUpMintSchema } from "@/lib/validations/step-up";
|
||||
@@ -51,50 +55,25 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const parsed = stepUpMintSchema.safeParse(body);
|
||||
if (!parsed.success) return proofRequired();
|
||||
|
||||
let proved = false;
|
||||
// Every proof counts against the account's shared re-proof bucket and every
|
||||
// refusal is audited. Without both, a stolen session could guess the account
|
||||
// password or a TOTP code here as fast as it could send requests.
|
||||
const ip = getClientIp(request);
|
||||
const limited = await throttleReproof(user.id, ip, "passkey_enroll");
|
||||
if (limited) return limited;
|
||||
|
||||
if (parsed.data.method === "password") {
|
||||
proved = Boolean(
|
||||
user.passwordHash &&
|
||||
(await verifyPassword(user.passwordHash, parsed.data.password)),
|
||||
const outcome = await verifyExistingFactorProof(user, parsed.data);
|
||||
if (!outcome.ok) {
|
||||
await recordReproofFailure(
|
||||
user.id,
|
||||
ip,
|
||||
"passkey_enroll",
|
||||
parsed.data.method,
|
||||
outcome.reason,
|
||||
);
|
||||
} else if (parsed.data.method === "totp") {
|
||||
const result = await verifyMfaFactor(user, "totp", parsed.data.code);
|
||||
proved = result.ok;
|
||||
} else {
|
||||
const expectedType =
|
||||
parsed.data.method === "passkey"
|
||||
? "authentication"
|
||||
: "mfa_authentication";
|
||||
const challenge = await prisma.authChallenge.findUnique({
|
||||
where: { id: parsed.data.challengeId },
|
||||
select: { userId: true, type: true },
|
||||
});
|
||||
|
||||
if (challenge?.userId === user.id && challenge.type === expectedType) {
|
||||
try {
|
||||
if (parsed.data.method === "passkey") {
|
||||
const result = await verifyAuthentication(
|
||||
parsed.data.challengeId,
|
||||
parsed.data.credential,
|
||||
);
|
||||
proved =
|
||||
result.verification.verified && result.passkey.userId === user.id;
|
||||
} else {
|
||||
proved = await verifyMfaAuthentication(
|
||||
parsed.data.challengeId,
|
||||
user.id,
|
||||
parsed.data.credential,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
proved = false;
|
||||
}
|
||||
}
|
||||
return proofRequired();
|
||||
}
|
||||
|
||||
if (!proved) return proofRequired();
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
recordReproofFailure,
|
||||
throttleReproof,
|
||||
} from "@/lib/auth/existing-factor-proof";
|
||||
import {
|
||||
apiHandler,
|
||||
requireFreshMfaIfEnrolled,
|
||||
@@ -35,14 +38,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// contract. Mirrors the step-up on MFA-disable + recovery-code regen.
|
||||
const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`auth:password:${user.id}`,
|
||||
5,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many attempts. Please wait 15 minutes.", 429);
|
||||
}
|
||||
// The current-password check below is a guessing oracle, so it draws on the
|
||||
// account's shared re-proof budget: a guess spent here is one the step-up
|
||||
// mint and the enrollment proofs cannot spend again.
|
||||
const ip = getClientIp(request);
|
||||
const limited = await throttleReproof(user.id, ip, "password_change");
|
||||
if (limited) return limited;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
@@ -62,6 +63,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const currentValid = await verifyPassword(user.passwordHash, currentPassword);
|
||||
if (!currentValid) {
|
||||
await recordReproofFailure(
|
||||
user.id,
|
||||
ip,
|
||||
"password_change",
|
||||
"password",
|
||||
"bad_password",
|
||||
);
|
||||
return apiError("Current password is incorrect", 401);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,16 +9,30 @@ import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { applyProfileUpdate } from "@/lib/auth/profile-update";
|
||||
import { authorizeSensitiveChange } from "@/lib/auth/existing-factor-proof";
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const auth = await requireAuth();
|
||||
const { user } = auth;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const result = await applyProfileUpdate(user.id, body, getClientIp(request));
|
||||
const ip = getClientIp(request);
|
||||
const result = await applyProfileUpdate(user.id, body, ip, {
|
||||
// A new email address needs a fresh proof: the address is what single
|
||||
// sign-on links an existing account by.
|
||||
authorizeEmailChange: (currentPassword) =>
|
||||
authorizeSensitiveChange({
|
||||
user,
|
||||
cookieSessionId: auth.authMethod === "cookie" ? auth.session.id : null,
|
||||
currentPassword,
|
||||
ipAddress: ip,
|
||||
stage: "email_change",
|
||||
}),
|
||||
});
|
||||
if (!result.ok) {
|
||||
const meta = result.errorCode ? { errorCode: result.errorCode } : undefined;
|
||||
return result.issues
|
||||
|
||||
@@ -40,16 +40,17 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
checkAuthSurfaceRateLimit,
|
||||
checkRateLimit,
|
||||
rateLimitHeaders,
|
||||
} from "@/lib/rate-limit";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn";
|
||||
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
|
||||
import {
|
||||
REPROOF_LIMIT,
|
||||
REPROOF_WINDOW_MS,
|
||||
reproofBucket,
|
||||
verifyExistingFactorProof,
|
||||
} from "@/lib/auth/existing-factor-proof";
|
||||
import {
|
||||
mintStepUpElevation,
|
||||
isFreshFactorMethod,
|
||||
@@ -65,8 +66,8 @@ export const dynamic = "force-dynamic";
|
||||
* against a user-supplied string — the same 5-per-15-minutes the password-change
|
||||
* route uses, for the same reason.
|
||||
*/
|
||||
const MINT_LIMIT = 5;
|
||||
const MINT_WINDOW_MS = 15 * 60 * 1000;
|
||||
const MINT_LIMIT = REPROOF_LIMIT;
|
||||
const MINT_WINDOW_MS = REPROOF_WINDOW_MS;
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const auth = await requireBearerAuth();
|
||||
@@ -91,8 +92,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
// The account's shared re-proof bucket: a guess spent here is a guess the
|
||||
// web enrollment surfaces cannot spend again, and the other way round.
|
||||
const userRl = await checkRateLimit(
|
||||
`auth:step-up:${user.id}`,
|
||||
reproofBucket(user.id),
|
||||
MINT_LIMIT,
|
||||
MINT_WINDOW_MS,
|
||||
);
|
||||
@@ -122,78 +125,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const method: StepUpMethod = parsed.data.method;
|
||||
let proved = false;
|
||||
// The shared verifier: the same challenge ownership and ceremony checks, and
|
||||
// the same confirmed-secret rule for TOTP, as every other re-proof surface.
|
||||
// The replay guard and the accepted-step burn behave exactly as they do at
|
||||
// login and at MFA-disable, so a code spent here cannot be replayed there.
|
||||
const outcome = await verifyExistingFactorProof(user, parsed.data);
|
||||
const proved = outcome.ok;
|
||||
// Audit-only detail. Never reaches the response — see the file header.
|
||||
let failure = "invalid";
|
||||
|
||||
/**
|
||||
* Resolve a WebAuthn challenge and confirm it belongs to THIS account before
|
||||
* any verification runs. Both ceremony helpers resolve a challenge by id
|
||||
* alone, so without this a challenge minted for another account could be
|
||||
* carried into the ceremony.
|
||||
*/
|
||||
const challengeBelongsToCaller = async (id: string): Promise<boolean> => {
|
||||
const row = await prisma.authChallenge.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true },
|
||||
});
|
||||
return Boolean(row && row.userId === user.id);
|
||||
};
|
||||
|
||||
if (parsed.data.method === "password") {
|
||||
if (!user.passwordHash) {
|
||||
// An SSO-provisioned account has no password to re-prove. Same refusal as
|
||||
// a wrong one: the account's credential shape is not a token holder's to
|
||||
// enumerate. `/api/auth/step-up/options` is the honest discovery path —
|
||||
// it 409s when there is no credential of the requested kind either.
|
||||
failure = "no_password";
|
||||
} else {
|
||||
proved = await verifyPassword(user.passwordHash, parsed.data.password);
|
||||
if (!proved) failure = "bad_password";
|
||||
}
|
||||
} else if (parsed.data.method === "totp") {
|
||||
// The shared factor verifier, so the replay guard and the accepted-step
|
||||
// burn behave exactly as they do at login and at MFA-disable. A code spent
|
||||
// here cannot be replayed there.
|
||||
const result = await verifyMfaFactor(user, "totp", parsed.data.code);
|
||||
proved = result.ok;
|
||||
if (!proved) failure = result.replay ? "totp_replay" : "bad_totp";
|
||||
} else if (parsed.data.method === "webauthn") {
|
||||
if (!(await challengeBelongsToCaller(parsed.data.challengeId))) {
|
||||
failure = "foreign_challenge";
|
||||
} else {
|
||||
try {
|
||||
// Scoped to the caller's own second-factor credentials by the verifier.
|
||||
proved = await verifyMfaAuthentication(
|
||||
parsed.data.challengeId,
|
||||
user.id,
|
||||
parsed.data.credential,
|
||||
);
|
||||
if (!proved) failure = "bad_assertion";
|
||||
} catch {
|
||||
// A malformed / expired challenge is a failed attempt, not a 500.
|
||||
failure = "bad_assertion";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!(await challengeBelongsToCaller(parsed.data.challengeId))) {
|
||||
failure = "foreign_challenge";
|
||||
} else {
|
||||
try {
|
||||
const result = await verifyAuthentication(
|
||||
parsed.data.challengeId,
|
||||
parsed.data.credential,
|
||||
);
|
||||
// Both halves matter: a verified assertion against SOMEONE ELSE'S
|
||||
// passkey proves possession of a factor, just not of this account's.
|
||||
proved =
|
||||
result.verification.verified && result.passkey.userId === user.id;
|
||||
if (!proved) failure = "bad_assertion";
|
||||
} catch {
|
||||
failure = "bad_assertion";
|
||||
}
|
||||
}
|
||||
}
|
||||
const failure = outcome.ok ? "" : outcome.reason;
|
||||
|
||||
if (!proved) {
|
||||
await auditLog("auth.stepup.mint.failed", {
|
||||
|
||||
@@ -2,7 +2,12 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
readBodyText,
|
||||
} from "@/lib/api-response";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -65,15 +70,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (Number.isFinite(declaredLength) && declaredLength > MAX_CSV_BYTES) {
|
||||
return apiError("CSV exceeds the 16 MB limit", 413);
|
||||
}
|
||||
// Counted while it is read, so a body without a Content-Length cannot run
|
||||
// past the cap before the check.
|
||||
let text: string;
|
||||
try {
|
||||
text = await request.text();
|
||||
const read = await readBodyText(request, MAX_CSV_BYTES);
|
||||
if (read.tooLarge) return apiError("CSV exceeds the 16 MB limit", 413);
|
||||
text = read.text;
|
||||
} catch {
|
||||
return apiError("Could not read the request body", 400);
|
||||
}
|
||||
if (Buffer.byteLength(text, "utf8") > MAX_CSV_BYTES) {
|
||||
return apiError("CSV exceeds the 16 MB limit", 413);
|
||||
}
|
||||
|
||||
const parsed = parseCsvMeasurements(text);
|
||||
if (parsed.fatal) {
|
||||
|
||||
@@ -30,7 +30,12 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
readBodyText,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
@@ -133,15 +138,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (Number.isFinite(declaredLength) && declaredLength > MAX_BYTES) {
|
||||
return apiError("File exceeds the 16 MB limit", 413);
|
||||
}
|
||||
// Counted while it is read, so a body without a Content-Length cannot run
|
||||
// past the cap before the check.
|
||||
let text: string;
|
||||
try {
|
||||
text = await request.text();
|
||||
const read = await readBodyText(request, MAX_BYTES);
|
||||
if (read.tooLarge) return apiError("File exceeds the 16 MB limit", 413);
|
||||
text = read.text;
|
||||
} catch {
|
||||
return apiError("Could not read the request body", 400);
|
||||
}
|
||||
if (Buffer.byteLength(text, "utf8") > MAX_BYTES) {
|
||||
return apiError("File exceeds the 16 MB limit", 413);
|
||||
}
|
||||
|
||||
const parsed = isJson ? parseAutoExportJson(text) : parseAutoExportCsv(text);
|
||||
if (parsed.fatal) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { applyProfileUpdate } from "@/lib/auth/profile-update";
|
||||
import { authorizeSensitiveChange } from "@/lib/auth/existing-factor-proof";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
@@ -97,14 +98,27 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const auth = await requireAuth();
|
||||
const { user } = auth;
|
||||
|
||||
const { data: body, error } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (error) return error;
|
||||
|
||||
const result = await applyProfileUpdate(user.id, body, getClientIp(request));
|
||||
const ip = getClientIp(request);
|
||||
const result = await applyProfileUpdate(user.id, body, ip, {
|
||||
// A new email address needs a fresh proof: the address is what single
|
||||
// sign-on links an existing account by.
|
||||
authorizeEmailChange: (currentPassword) =>
|
||||
authorizeSensitiveChange({
|
||||
user,
|
||||
cookieSessionId: auth.authMethod === "cookie" ? auth.session.id : null,
|
||||
currentPassword,
|
||||
ipAddress: ip,
|
||||
stage: "email_change",
|
||||
}),
|
||||
});
|
||||
if (!result.ok) {
|
||||
const meta = result.errorCode ? { errorCode: result.errorCode } : undefined;
|
||||
return result.issues
|
||||
|
||||
+43
-29
@@ -32,7 +32,14 @@ export default function LoginPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const [mode, setMode] = useState<"passkey" | "password">("passkey");
|
||||
// The SSO callback sends a browser here when its email matches an account
|
||||
// that exists but is not linked yet: the person signs in to that account
|
||||
// once with its own credential, which links it. Under an SSO-only policy
|
||||
// this is the one case the password form is shown for.
|
||||
const linkPending = searchParams.get("error") === "oidc_link_required";
|
||||
const [mode, setMode] = useState<"passkey" | "password">(
|
||||
linkPending ? "password" : "passkey",
|
||||
);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(() => {
|
||||
@@ -43,6 +50,7 @@ export default function LoginPage() {
|
||||
oidc_no_email: "auth.oidc.errorNoEmail",
|
||||
oidc_email_unverified: "auth.oidc.errorEmailUnverified",
|
||||
oidc_identity_conflict: "auth.oidc.errorIdentityConflict",
|
||||
oidc_link_required: "auth.oidc.errorLinkRequired",
|
||||
oidc_registration_disabled: "auth.oidc.errorRegistrationDisabled",
|
||||
};
|
||||
return t(key[oidcError] ?? "auth.oidc.errorFailed");
|
||||
@@ -394,33 +402,37 @@ export default function LoginPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!oidcStatus?.only && (
|
||||
{(!oidcStatus?.only || linkPending) && (
|
||||
<>
|
||||
{/* Phase A5 / B-mobile: bumped from default size (h-9, 36px)
|
||||
to size="lg" so the login CTAs meet WCAG 2.5.5 (44px
|
||||
minimum) on mobile. Login is the most-tapped flow on a
|
||||
fresh install. */}
|
||||
<Button
|
||||
onClick={handlePasskeyLogin}
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && mode === "passkey" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<KeyRound className="h-4 w-4" />
|
||||
)}
|
||||
{t("auth.loginWithPasskey")}
|
||||
</Button>
|
||||
{!oidcStatus?.only && (
|
||||
<>
|
||||
<Button
|
||||
onClick={handlePasskeyLogin}
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && mode === "passkey" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<KeyRound className="h-4 w-4" />
|
||||
)}
|
||||
{t("auth.loginWithPasskey")}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("common.or")}
|
||||
</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("common.or")}
|
||||
</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === "passkey" ? (
|
||||
<Button
|
||||
@@ -488,13 +500,15 @@ export default function LoginPage() {
|
||||
)}
|
||||
{t("auth.login")}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("passkey")}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
|
||||
>
|
||||
{t("auth.backToPasskey")}
|
||||
</button>
|
||||
{!oidcStatus?.only && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("passkey")}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
|
||||
>
|
||||
{t("auth.backToPasskey")}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -116,7 +116,11 @@ describe("change-password card placement", () => {
|
||||
"settings.passwordReset",
|
||||
"settings.changePassword",
|
||||
"/api/auth/password",
|
||||
"current-password",
|
||||
// A change-password form is the pair current + new. The Account form
|
||||
// asks for the CURRENT password alone, beside a new email address,
|
||||
// which is a proof and not a password change; the new-password half is
|
||||
// what would mean the old form came back.
|
||||
"new-password",
|
||||
]) {
|
||||
expect(account, `Account still carries ${marker}`).not.toContain(marker);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,10 @@ export function AccountSection() {
|
||||
);
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
// A new email address needs the current password unless the session signed
|
||||
// in within the last few minutes; the server decides and names the field if
|
||||
// it wanted one. Never stored beyond this form.
|
||||
const [emailPassword, setEmailPassword] = useState("");
|
||||
const [height, setHeight] = useState<HeightDraft>(EMPTY_HEIGHT_DRAFT);
|
||||
const [dateOfBirth, setDateOfBirth] = useState("");
|
||||
const [gender, setGender] = useState<string>("");
|
||||
@@ -185,6 +189,10 @@ export function AccountSection() {
|
||||
navigate,
|
||||
});
|
||||
|
||||
const emailChanged =
|
||||
email.trim() !== "" &&
|
||||
email.trim().toLowerCase() !== profileSeed.email.trim().toLowerCase();
|
||||
|
||||
async function handleSaveProfile(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
@@ -212,6 +220,9 @@ export function AccountSection() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: savedProfile.email || null,
|
||||
...(emailChanged && emailPassword
|
||||
? { currentPassword: emailPassword }
|
||||
: {}),
|
||||
heightCm: heightAdapter.toCanonicalCm(savedProfile.height),
|
||||
dateOfBirth: savedProfile.dateOfBirth || null,
|
||||
gender: savedProfile.gender || null,
|
||||
@@ -229,6 +240,8 @@ export function AccountSection() {
|
||||
: Promise.resolve({ ok: true } as Response),
|
||||
]);
|
||||
|
||||
setEmailPassword("");
|
||||
|
||||
if (profileRes.ok && tzRes.ok) {
|
||||
// Instant same-tab flip of every rendered timestamp (issue #490):
|
||||
// mirror the accepted zone before the `/me` refetch confirms it —
|
||||
@@ -373,6 +386,24 @@ export function AccountSection() {
|
||||
aria-describedby={fieldErrors.email ? "email-error" : undefined}
|
||||
/>
|
||||
<FieldError id="email-error" message={fieldErrors.email} />
|
||||
{emailChanged && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<Label htmlFor="email-current-password">
|
||||
{t("settings.emailChangePassword")}
|
||||
</Label>
|
||||
<Input
|
||||
id="email-current-password"
|
||||
type="password"
|
||||
value={emailPassword}
|
||||
onChange={(e) => {
|
||||
setEmailPassword(e.target.value);
|
||||
clearFieldError("email");
|
||||
}}
|
||||
autoComplete="current-password"
|
||||
maxLength={512}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* TOTP and security-key enrollment ask for a fresh proof when the server says
|
||||
* so (401 `auth.reproof.required`), and retry the SAME enrollment call with it.
|
||||
*
|
||||
* Source-oriented like the passkey reauth test next door: the repository keeps
|
||||
* component tests free of a browser DOM harness. The behavioural half — that
|
||||
* the server refuses without the proof — is pinned against Postgres in
|
||||
* `tests/integration/credential-enrollment-proof.test.ts`.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { isReproofRequired } from "../existing-factor-reauth-dialog";
|
||||
|
||||
const read = (file: string) =>
|
||||
readFileSync(resolve(__dirname, "..", file), "utf8");
|
||||
|
||||
describe("isReproofRequired", () => {
|
||||
it("recognises only the re-proof refusal", () => {
|
||||
expect(
|
||||
isReproofRequired(
|
||||
new ApiError("x", 401, { errorCode: "auth.reproof.required" }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isReproofRequired(
|
||||
new ApiError("x", 401, { errorCode: "auth.reproof.failed" }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isReproofRequired(
|
||||
new ApiError("x", 401, { errorCode: "auth.stepup.required" }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isReproofRequired(new Error("x"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["totp-card.tsx", '"/api/auth/me/mfa/totp/setup"', "beginSetup"],
|
||||
[
|
||||
"security-keys-card.tsx",
|
||||
'"/api/auth/me/mfa/webauthn/register/options"',
|
||||
"add",
|
||||
],
|
||||
])("%s", (file, endpoint, mutation) => {
|
||||
const source = read(file);
|
||||
|
||||
it("sends the collected proof on the enrollment call itself", () => {
|
||||
expect(source).toContain(endpoint);
|
||||
expect(source).toMatch(
|
||||
new RegExp(`${endpoint.replace(/[/.]/g, "\\$&")},\\s*proof\\)`),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the re-proof dialog on the server's cue and retries with the proof", () => {
|
||||
expect(source).toContain("isReproofRequired(err)");
|
||||
expect(source).toContain("setReauthOpen(true)");
|
||||
expect(source).toContain(`onProof={(proof) => ${mutation}.mutate(proof)}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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";
|
||||
|
||||
/** The body the enrollment routes accept as a fresh existing-factor proof. */
|
||||
export type ExistingFactorProof =
|
||||
| { method: "password"; password: string }
|
||||
| { method: "totp"; code: string }
|
||||
| {
|
||||
method: "passkey" | "webauthn";
|
||||
challengeId: string;
|
||||
credential: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* The server answers an enrollment it will not take on the session alone with
|
||||
* 401 `auth.reproof.required`. That is the cue to ask for a proof and retry,
|
||||
* not an error to show.
|
||||
*/
|
||||
export function isReproofRequired(err: unknown): boolean {
|
||||
return (
|
||||
err instanceof ApiError &&
|
||||
err.status === 401 &&
|
||||
err.meta?.errorCode === "auth.reproof.required"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect a fresh proof of a credential the account already holds, before a
|
||||
* 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.
|
||||
*/
|
||||
export function ExistingFactorReauthDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
methods,
|
||||
pending,
|
||||
error,
|
||||
onProof,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
methods: ReauthMethod[];
|
||||
pending: boolean;
|
||||
error: string | null;
|
||||
onProof: (proof: ExistingFactorProof) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [method, setMethod] = useState<ReauthMethod>(methods[0] ?? "password");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [assertionError, setAssertionError] = useState<string | null>(null);
|
||||
const [asserting, setAsserting] = useState(false);
|
||||
const busy = pending || asserting;
|
||||
const shown = assertionError ?? error;
|
||||
|
||||
async function submit() {
|
||||
setAssertionError(null);
|
||||
if (method === "password") {
|
||||
onProof({ method, password: secret });
|
||||
return;
|
||||
}
|
||||
if (method === "totp") {
|
||||
onProof({ method, code: secret });
|
||||
return;
|
||||
}
|
||||
setAsserting(true);
|
||||
try {
|
||||
const webauthn = await import("@simplewebauthn/browser");
|
||||
const begun = await apiPost<{
|
||||
options: Parameters<
|
||||
typeof webauthn.startAuthentication
|
||||
>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
}>("/api/auth/passkey/register-options", { method });
|
||||
const credential = await webauthn.startAuthentication({
|
||||
optionsJSON: begun.options,
|
||||
});
|
||||
onProof({ method, challengeId: begun.challengeId, credential });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setAssertionError(err.message);
|
||||
} else {
|
||||
const { key, params } = describePasskeyError(err);
|
||||
setAssertionError(t(key, params));
|
||||
}
|
||||
} finally {
|
||||
setAsserting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const labels: Record<ReauthMethod, string> = {
|
||||
password: t("settings.passkeyReauth.methods.password"),
|
||||
totp: t("settings.passkeyReauth.methods.totp"),
|
||||
passkey: t("settings.passkeyReauth.methods.passkey"),
|
||||
webauthn: t("settings.passkeyReauth.methods.webauthn"),
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (busy) return;
|
||||
if (!next) {
|
||||
setSecret("");
|
||||
setAssertionError(null);
|
||||
}
|
||||
onOpenChange(next);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent data-testid="factor-reauth-dialog">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.passkeyReauth.title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.passkeyReauth.factorDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
{methods.length > 1 && (
|
||||
<div
|
||||
className="grid grid-cols-2 gap-2"
|
||||
role="group"
|
||||
aria-label={t("settings.passkeyReauth.methodLabel")}
|
||||
>
|
||||
{methods.map((m) => (
|
||||
<Button
|
||||
key={m}
|
||||
type="button"
|
||||
variant={method === m ? "secondary" : "outline"}
|
||||
aria-pressed={method === m}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setMethod(m);
|
||||
setSecret("");
|
||||
setAssertionError(null);
|
||||
}}
|
||||
>
|
||||
{labels[m]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{method === "password" && (
|
||||
<Input
|
||||
type="password"
|
||||
data-testid="factor-reauth-password"
|
||||
autoComplete="current-password"
|
||||
value={secret}
|
||||
onChange={(event) => setSecret(event.target.value)}
|
||||
placeholder={t("settings.passkeyReauth.currentPassword")}
|
||||
aria-label={t("settings.passkeyReauth.currentPassword")}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
{method === "totp" && (
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
value={secret}
|
||||
onChange={(event) =>
|
||||
setSecret(event.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
placeholder={t("settings.passkeyReauth.authenticatorPlaceholder")}
|
||||
aria-label={t("settings.passkeyReauth.authenticatorCode")}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
|
||||
{shown && (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive flex items-center gap-2 text-sm"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
{shown}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busy}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
data-testid="factor-reauth-submit"
|
||||
disabled={
|
||||
busy ||
|
||||
((method === "password" || method === "totp") &&
|
||||
secret.length === 0)
|
||||
}
|
||||
aria-busy={busy || undefined}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.passkeyReauth.verifyAndContinue")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -63,7 +63,10 @@ export function SecuritySection() {
|
||||
recoveryCodesRemaining={status.recoveryCodesRemaining}
|
||||
/>
|
||||
|
||||
<SecurityKeysCard keys={status.webauthn} />
|
||||
<SecurityKeysCard
|
||||
keys={status.webauthn}
|
||||
totpEnabled={status.totp.enabled}
|
||||
/>
|
||||
|
||||
<PasskeyListSection isAuthenticated={isAuthenticated} />
|
||||
</>
|
||||
|
||||
@@ -33,6 +33,12 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import {
|
||||
ExistingFactorReauthDialog,
|
||||
isReproofRequired,
|
||||
type ExistingFactorProof,
|
||||
type ReauthMethod,
|
||||
} from "./existing-factor-reauth-dialog";
|
||||
|
||||
export interface WebauthnKeyInfo {
|
||||
id: string;
|
||||
@@ -74,20 +80,38 @@ export function describeStepUp(
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) {
|
||||
export function SecurityKeysCard({
|
||||
keys,
|
||||
totpEnabled = false,
|
||||
}: {
|
||||
keys: WebauthnKeyInfo[];
|
||||
totpEnabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
|
||||
// Adding a key right after signing in goes straight through; later, the
|
||||
// server asks for a fresh proof first (401 `auth.reproof.required`) and the
|
||||
// options call is retried with it from the dialog.
|
||||
const [reauthOpen, setReauthOpen] = useState(false);
|
||||
const [reauthError, setReauthError] = useState<string | null>(null);
|
||||
const reauthMethods: ReauthMethod[] = [
|
||||
"password",
|
||||
...(totpEnabled ? (["totp"] as const) : []),
|
||||
"passkey",
|
||||
...(keys.length > 0 ? (["webauthn"] as const) : []),
|
||||
];
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: async () => {
|
||||
mutationFn: async (proof?: ExistingFactorProof) => {
|
||||
const { startRegistration } = await import("@simplewebauthn/browser");
|
||||
const { options, challengeId } = await apiPost<{
|
||||
options: Parameters<typeof startRegistration>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
}>("/api/auth/me/mfa/webauthn/register/options");
|
||||
}>("/api/auth/me/mfa/webauthn/register/options", proof);
|
||||
const credential = await startRegistration({ optionsJSON: options });
|
||||
await apiPost("/api/auth/me/mfa/webauthn/register/verify", {
|
||||
challengeId,
|
||||
@@ -96,14 +120,28 @@ export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) {
|
||||
},
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
setReauthOpen(false);
|
||||
setReauthError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
|
||||
},
|
||||
onError: (err) => {
|
||||
onError: (err, proof) => {
|
||||
if (isReproofRequired(err)) {
|
||||
setError(null);
|
||||
setReauthError(null);
|
||||
setReauthOpen(true);
|
||||
return;
|
||||
}
|
||||
let message: string;
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message || t("settings.security.keys.addFailed"));
|
||||
message = err.message || t("settings.security.keys.addFailed");
|
||||
} else {
|
||||
const { key, params } = describePasskeyError(err);
|
||||
setError(t(key, params));
|
||||
message = t(key, params);
|
||||
}
|
||||
if (proof) {
|
||||
setReauthError(message);
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -294,7 +332,7 @@ export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) {
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => add.mutate()}
|
||||
onClick={() => add.mutate(undefined)}
|
||||
disabled={add.isPending}
|
||||
>
|
||||
{add.isPending ? (
|
||||
@@ -305,6 +343,17 @@ export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) {
|
||||
{t("settings.security.keys.add")}
|
||||
</Button>
|
||||
</div>
|
||||
<ExistingFactorReauthDialog
|
||||
open={reauthOpen}
|
||||
onOpenChange={(open) => {
|
||||
setReauthOpen(open);
|
||||
if (!open) setReauthError(null);
|
||||
}}
|
||||
methods={reauthMethods}
|
||||
pending={add.isPending}
|
||||
error={reauthError}
|
||||
onProof={(proof) => add.mutate(proof)}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
|
||||
@@ -33,6 +33,11 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { RecoveryCodesPanel } from "./recovery-codes-panel";
|
||||
import {
|
||||
ExistingFactorReauthDialog,
|
||||
isReproofRequired,
|
||||
type ExistingFactorProof,
|
||||
} from "./existing-factor-reauth-dialog";
|
||||
|
||||
interface SetupData {
|
||||
otpauthUri: string;
|
||||
@@ -118,12 +123,20 @@ export function TotpCard({
|
||||
setError(null);
|
||||
}
|
||||
|
||||
// Enrollment right after signing in goes straight through; later, the server
|
||||
// asks for a fresh proof first (401 `auth.reproof.required`), and the setup
|
||||
// call is retried with it from the dialog.
|
||||
const [reauthOpen, setReauthOpen] = useState(false);
|
||||
const [reauthError, setReauthError] = useState<string | null>(null);
|
||||
|
||||
const beginSetup = useMutation({
|
||||
mutationFn: async () => {
|
||||
return apiPost<SetupData>("/api/auth/me/mfa/totp/setup");
|
||||
mutationFn: async (proof?: ExistingFactorProof) => {
|
||||
return apiPost<SetupData>("/api/auth/me/mfa/totp/setup", proof);
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
setError(null);
|
||||
setReauthOpen(false);
|
||||
setReauthError(null);
|
||||
setSetup(data);
|
||||
try {
|
||||
const QRCode = (await import("qrcode")).default;
|
||||
@@ -133,14 +146,21 @@ export function TotpCard({
|
||||
setQrDataUrl(null);
|
||||
}
|
||||
},
|
||||
onError: (err) =>
|
||||
setError(
|
||||
describeError(
|
||||
err,
|
||||
t("settings.security.totp.setupFailed"),
|
||||
t("settings.security.stepUpRequired"),
|
||||
),
|
||||
),
|
||||
onError: (err, proof) => {
|
||||
if (isReproofRequired(err)) {
|
||||
setError(null);
|
||||
setReauthError(null);
|
||||
setReauthOpen(true);
|
||||
return;
|
||||
}
|
||||
const message = describeError(
|
||||
err,
|
||||
t("settings.security.totp.setupFailed"),
|
||||
t("settings.security.stepUpRequired"),
|
||||
);
|
||||
if (proof) setReauthError(message);
|
||||
else setError(message);
|
||||
},
|
||||
});
|
||||
|
||||
const confirm = useMutation({
|
||||
@@ -241,7 +261,7 @@ export function TotpCard({
|
||||
type="button"
|
||||
data-testid="totp-setup-start"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => beginSetup.mutate()}
|
||||
onClick={() => beginSetup.mutate(undefined)}
|
||||
disabled={beginSetup.isPending}
|
||||
>
|
||||
{beginSetup.isPending ? (
|
||||
@@ -252,6 +272,17 @@ export function TotpCard({
|
||||
{t("settings.security.totp.setUp")}
|
||||
</Button>
|
||||
)}
|
||||
<ExistingFactorReauthDialog
|
||||
open={reauthOpen}
|
||||
onOpenChange={(open) => {
|
||||
setReauthOpen(open);
|
||||
if (!open) setReauthError(null);
|
||||
}}
|
||||
methods={["password", "passkey", "webauthn"]}
|
||||
pending={beginSetup.isPending}
|
||||
error={reauthError}
|
||||
onProof={(proof) => beginSetup.mutate(proof)}
|
||||
/>
|
||||
|
||||
{/* ── Enable wizard ── */}
|
||||
{setup && (
|
||||
|
||||
@@ -61,6 +61,17 @@ describe("getClientIp trusted-proxy semantics (V3 audit)", () => {
|
||||
).toThrow(/TRUST_PROXY_HOPS/);
|
||||
});
|
||||
|
||||
it.each(["", " "])(
|
||||
"treats an empty TRUST_PROXY_HOPS (%j) as unset — the default compose file passes one",
|
||||
(value) => {
|
||||
process.env.TRUST_PROXY_HOPS = value;
|
||||
const ip = getClientIp(
|
||||
makeRequest({ "x-forwarded-for": "9.9.9.9, 1.2.3.4, 5.6.7.8" }),
|
||||
);
|
||||
expect(ip).toBe("5.6.7.8");
|
||||
},
|
||||
);
|
||||
|
||||
it("with TRUST_PROXY_HOPS=0 ignores XFF entirely", () => {
|
||||
process.env.TRUST_PROXY_HOPS = "0";
|
||||
const ip = getClientIp(
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* `safeJson`'s `maxBytes` bounds the READ, not just the parse.
|
||||
*
|
||||
* It used to call `request.text()` and compare afterwards, so the whole body
|
||||
* was held before the cap was consulted. That was tolerable while the proxy
|
||||
* truncated every body at a fixed ceiling; the large-upload routes no longer
|
||||
* pass through the proxy, so the cap has to hold on its own — including for a
|
||||
* body that declares no length.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { readBodyText, safeJson } from "@/lib/api-response";
|
||||
|
||||
/** An endless body of 1 KB chunks that counts how many were pulled. */
|
||||
function endless(): {
|
||||
stream: ReadableStream<Uint8Array>;
|
||||
pulled: () => number;
|
||||
} {
|
||||
let pulls = 0;
|
||||
const chunk = new TextEncoder().encode("x".repeat(1024));
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
pulls += 1;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
return { stream, pulled: () => pulls };
|
||||
}
|
||||
|
||||
function jsonRequest(
|
||||
body: BodyInit,
|
||||
headers: Record<string, string> = {},
|
||||
): Request {
|
||||
return new Request("http://localhost/api/x", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body,
|
||||
// Required by undici for a stream body.
|
||||
duplex: "half",
|
||||
} as RequestInit);
|
||||
}
|
||||
|
||||
describe("bounded body read", () => {
|
||||
it("stops a body without a declared length at the cap", async () => {
|
||||
const { stream, pulled } = endless();
|
||||
const res = await safeJson(jsonRequest(stream), { maxBytes: 16 * 1024 });
|
||||
expect(res.error?.status).toBe(413);
|
||||
expect(pulled()).toBeLessThan(40);
|
||||
});
|
||||
|
||||
it("refuses a declared oversize body before reading any of it", async () => {
|
||||
const { stream, pulled } = endless();
|
||||
const read = await readBodyText(
|
||||
jsonRequest(stream, { "content-length": String(50 * 1024 * 1024) }),
|
||||
1024 * 1024,
|
||||
);
|
||||
expect(read.tooLarge).toBe(true);
|
||||
// A pull may be scheduled when the stream is constructed; none after.
|
||||
expect(pulled()).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("parses a body under the cap, multibyte text intact", async () => {
|
||||
const res = await safeJson<{ note: string }>(
|
||||
jsonRequest(JSON.stringify({ note: "Blutdruck gemessen – ok" })),
|
||||
{ maxBytes: 1024 },
|
||||
);
|
||||
expect(res.data).toEqual({ note: "Blutdruck gemessen – ok" });
|
||||
});
|
||||
|
||||
it("counts bytes, not characters", async () => {
|
||||
// 600 three-byte characters: 600 chars, 1800 bytes.
|
||||
const body = JSON.stringify({ s: "€".repeat(600) });
|
||||
const res = await safeJson(jsonRequest(body), { maxBytes: 1024 });
|
||||
expect(res.error?.status).toBe(413);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import type { AccountGrant, User } from "@/generated/prisma/client";
|
||||
import {
|
||||
applyBypassRouteHeaders,
|
||||
bypassRouteRefusal,
|
||||
isProxyBypassRoute,
|
||||
} from "@/lib/http/proxy-bypass-routes";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { WideEventBuilder } from "./logging/event-builder";
|
||||
import { annotate, eventStorage, getEvent } from "./logging/context";
|
||||
@@ -200,6 +205,19 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
|
||||
}
|
||||
})();
|
||||
|
||||
// A route the proxy matcher leaves out (large uploads, see
|
||||
// `proxy-bypass-routes.ts`) gets the proxy's refusals and headers here.
|
||||
const isBypassRoute = isProxyBypassRoute(url.pathname);
|
||||
if (isBypassRoute) {
|
||||
const refusal = bypassRouteRefusal(
|
||||
safeRequestProp(request, (r) => r.method, "GET"),
|
||||
);
|
||||
if (refusal) {
|
||||
applyBypassRouteHeaders(refusal.headers);
|
||||
return refusal;
|
||||
}
|
||||
}
|
||||
|
||||
const evt = new WideEventBuilder("http");
|
||||
|
||||
// Propagate x-request-id if present
|
||||
@@ -330,6 +348,7 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
|
||||
}
|
||||
const nr = response as NextResponse;
|
||||
nr.headers.set("x-request-id", evt.getRequestId());
|
||||
if (isBypassRoute) applyBypassRouteHeaders(nr.headers);
|
||||
// v1.37.0 — echo the record context this response was actually served
|
||||
// under, when one was decided. The value comes from the wide event and
|
||||
// from nowhere else: the fence stamps it on every call it makes, so
|
||||
|
||||
+66
-17
@@ -287,21 +287,66 @@ export function apiError(message: string, status = 400, meta?: ErrorMeta) {
|
||||
return buildJsonErrorResponse({ data: null, error: message }, status, meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a request body as text, counting BYTES as they arrive and stopping at
|
||||
* `maxBytes`. A body that declares a larger `Content-Length` is refused before
|
||||
* a byte is read; one that does not (chunked) is cancelled the moment it
|
||||
* passes the cap. `request.text()` has no such bound — it holds whatever the
|
||||
* client sends — which mattered little while the proxy's body clone truncated
|
||||
* everything at a fixed ceiling, and matters for the upload routes that no
|
||||
* longer pass through the proxy.
|
||||
*/
|
||||
export async function readBodyText(
|
||||
request: Request,
|
||||
maxBytes: number,
|
||||
): Promise<{ text: string; tooLarge?: never } | { tooLarge: true }> {
|
||||
const declared = Number(request.headers.get("content-length") ?? "");
|
||||
if (Number.isFinite(declared) && declared > maxBytes) {
|
||||
return { tooLarge: true };
|
||||
}
|
||||
const body = request.body;
|
||||
if (!body) return { text: "" };
|
||||
const reader = body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
void reader.cancel().catch(() => {});
|
||||
return { tooLarge: true };
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// Already released by the cancel above.
|
||||
}
|
||||
}
|
||||
const joined = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
joined.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return { text: new TextDecoder().decode(joined) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse JSON body from a request.
|
||||
* Returns the parsed body or a 400 error response if parsing fails.
|
||||
*
|
||||
* `opts.maxBytes` opts a route into a hard body-size cap. The check
|
||||
* runs against the raw text length rather than the parsed object, so an
|
||||
* over-limit payload returns 413 before reaching `JSON.parse` and never
|
||||
* builds an object graph. (`request.text()` still buffers the raw body
|
||||
* into a string, so the cap bounds the parse/object cost, not the
|
||||
* initial read — keep caps comfortably above the largest legitimate
|
||||
* payload.) Single-record routes can pass a tight cap; batch routes
|
||||
* that legitimately accept large payloads pass a larger one. When
|
||||
* `maxBytes` is omitted the route inherits the Next.js runtime default —
|
||||
* pre-existing behaviour, no regression risk for callers that haven't
|
||||
* adopted the parameter.
|
||||
* `opts.maxBytes` opts a route into a hard body-size cap, enforced WHILE the
|
||||
* body is read (`readBodyText`): an over-limit payload returns 413 without the
|
||||
* whole body ever being held, and never reaches `JSON.parse`. Single-record
|
||||
* routes can pass a tight cap; batch routes that legitimately accept large
|
||||
* payloads pass a larger one. When `maxBytes` is omitted the body is bounded
|
||||
* only by the proxy's 1 MB clone ceiling (`proxyClientMaxBodySize`), so a
|
||||
* route left out of the proxy matcher must pass one.
|
||||
*/
|
||||
export async function safeJson<T = unknown>(
|
||||
request: Request,
|
||||
@@ -312,19 +357,19 @@ export async function safeJson<T = unknown>(
|
||||
return { error: apiError("Content-Type must be application/json", 415) };
|
||||
}
|
||||
if (opts?.maxBytes !== undefined) {
|
||||
let raw: string;
|
||||
let read: Awaited<ReturnType<typeof readBodyText>>;
|
||||
try {
|
||||
raw = await request.text();
|
||||
read = await readBodyText(request, opts.maxBytes);
|
||||
} catch {
|
||||
return { error: apiError("Invalid request body", 400) };
|
||||
}
|
||||
if (raw.length > opts.maxBytes) {
|
||||
if (read.tooLarge) {
|
||||
return {
|
||||
error: apiError(`Request body exceeds ${opts.maxBytes} bytes`, 413),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(raw) as T;
|
||||
const data = JSON.parse(read.text) as T;
|
||||
return { data };
|
||||
} catch {
|
||||
return { error: apiError("Invalid JSON body", 400) };
|
||||
@@ -400,8 +445,12 @@ function readCfConnectingIp(request: Request): string | null {
|
||||
}
|
||||
|
||||
function parseTrustProxyHops(raw: string | undefined): number {
|
||||
if (raw === undefined) return 1;
|
||||
const trimmed = raw.trim();
|
||||
// Empty is unset. The shipped docker-compose.yml passes
|
||||
// `TRUST_PROXY_HOPS: "${TRUST_PROXY_HOPS:-}"`, which is an empty string for
|
||||
// every operator who never set it; reading that as invalid threw on every
|
||||
// request that asked for the client IP.
|
||||
const trimmed = raw?.trim() ?? "";
|
||||
if (trimmed === "") return 1;
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
// Reject explicitly-invalid values so an operator typo doesn't silently
|
||||
// switch a real-proxy deployment to "no XFF trust" mode (review
|
||||
|
||||
@@ -33,7 +33,18 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
checkProfileEmailRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
import { applyProfileUpdate } from "../profile-update";
|
||||
import { applyProfileUpdate as applyWithProof } from "../profile-update";
|
||||
|
||||
/** These suites are about validation and the email budget, not the proof:
|
||||
* every call is made as an owner who has just proved themselves. */
|
||||
const applyProfileUpdate = (
|
||||
userId: string,
|
||||
body: unknown,
|
||||
ipAddress?: string | null,
|
||||
) =>
|
||||
applyWithProof(userId, body, ipAddress, {
|
||||
authorizeEmailChange: async () => "ok",
|
||||
});
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkProfileEmailRateLimit } from "@/lib/rate-limit";
|
||||
@@ -238,3 +249,72 @@ describe("applyProfileUpdate — the email conflict is metered", () => {
|
||||
expect(checkProfileEmailRateLimit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyProfileUpdate — a new address needs a fresh proof", () => {
|
||||
it("refuses an address-only change without asking about the address", async () => {
|
||||
const authorizeEmailChange = vi.fn(async () => "required" as const);
|
||||
const result = await applyWithProof(
|
||||
USER_ID,
|
||||
{ email: "someone@example.test" },
|
||||
null,
|
||||
{ authorizeEmailChange },
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(401);
|
||||
expect(result.errorCode).toBe("auth.reproof.required");
|
||||
}
|
||||
expect(emailProbes()).toHaveLength(0);
|
||||
expect(checkProfileEmailRateLimit).not.toHaveBeenCalled();
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops only the address when the save carries other fields", async () => {
|
||||
const result = await applyWithProof(
|
||||
USER_ID,
|
||||
{ email: "someone@example.test", heightCm: 180 },
|
||||
null,
|
||||
{ authorizeEmailChange: async () => "failed" },
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.rejectedFields).toEqual([
|
||||
expect.objectContaining({ path: "email", code: "reproof_failed" }),
|
||||
]);
|
||||
}
|
||||
const written = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(written.data).not.toHaveProperty("email");
|
||||
expect(written.data).toHaveProperty("heightCm", 180);
|
||||
});
|
||||
|
||||
it("hands the body's current password to the proof and never writes it", async () => {
|
||||
const authorizeEmailChange = vi.fn(async () => "ok" as const);
|
||||
await applyWithProof(
|
||||
USER_ID,
|
||||
{ email: "someone@example.test", currentPassword: "hunter2hunter2" },
|
||||
null,
|
||||
{ authorizeEmailChange },
|
||||
);
|
||||
expect(authorizeEmailChange).toHaveBeenCalledWith("hunter2hunter2");
|
||||
const written = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(JSON.stringify(written.data)).not.toContain("hunter2");
|
||||
});
|
||||
|
||||
it("does not ask when the address on file is re-posted", async () => {
|
||||
const authorizeEmailChange = vi.fn(async () => "required" as const);
|
||||
const result = await applyWithProof(
|
||||
USER_ID,
|
||||
{ email: ON_FILE, heightCm: 170 },
|
||||
null,
|
||||
{ authorizeEmailChange },
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(authorizeEmailChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,18 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { applyProfileUpdate } from "../profile-update";
|
||||
import { applyProfileUpdate as applyWithProof } from "../profile-update";
|
||||
|
||||
/** These suites are about validation and the email budget, not the proof:
|
||||
* every call is made as an owner who has just proved themselves. */
|
||||
const applyProfileUpdate = (
|
||||
userId: string,
|
||||
body: unknown,
|
||||
ipAddress?: string | null,
|
||||
) =>
|
||||
applyWithProof(userId, body, ipAddress, {
|
||||
authorizeEmailChange: async () => "ok",
|
||||
});
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const USER_ID = "user-1";
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Re-proving a credential the account already holds.
|
||||
*
|
||||
* Several surfaces accept a fresh proof in the request body before they act:
|
||||
* the Bearer step-up mint, the primary-passkey enrollment, TOTP enrollment and
|
||||
* security-key enrollment. They used to verify it each in their own way, and
|
||||
* the copies drifted — one checked who owned a WebAuthn challenge but not its
|
||||
* type, one had no rate limit and wrote no audit row for a wrong password, so
|
||||
* a stolen session could guess the password or a TOTP code there without
|
||||
* limit and without trace. This module is the one verifier and the one
|
||||
* throttle, so every surface refuses, counts and records a failed proof the
|
||||
* same way.
|
||||
*
|
||||
* The throttle is ONE bucket per account shared by every surface: five proofs
|
||||
* in fifteen minutes, the ceiling the step-up mint has used since it shipped
|
||||
* (same key, so its counter carries straight over). A shared bucket means a
|
||||
* guesser cannot multiply the budget by rotating between routes.
|
||||
*/
|
||||
import type { User } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn";
|
||||
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
|
||||
import {
|
||||
stepUpMintSchema,
|
||||
type StepUpMintRequest,
|
||||
} from "@/lib/validations/step-up";
|
||||
|
||||
export type ExistingFactorProof = StepUpMintRequest;
|
||||
|
||||
export const REPROOF_LIMIT = 5;
|
||||
export const REPROOF_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
/** The per-account bucket every re-proof surface draws from. */
|
||||
export function reproofBucket(userId: string): string {
|
||||
return `auth:step-up:${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge one attempt to the account's re-proof bucket. Returns the 429 to send
|
||||
* when the bucket is empty (and writes the audit row), or null to proceed.
|
||||
* Call it only when a proof is actually about to be verified, so a request that
|
||||
* needs no proof does not spend one.
|
||||
*/
|
||||
export async function throttleReproof(
|
||||
userId: string,
|
||||
ipAddress: string | null,
|
||||
stage: string,
|
||||
): Promise<Response | null> {
|
||||
const rl = await checkRateLimit(
|
||||
reproofBucket(userId),
|
||||
REPROOF_LIMIT,
|
||||
REPROOF_WINDOW_MS,
|
||||
);
|
||||
if (rl.allowed) return null;
|
||||
await auditLog("auth.reproof.rate_limited", {
|
||||
userId,
|
||||
ipAddress,
|
||||
details: { stage },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.reproof.rate_limited" },
|
||||
meta: { stage },
|
||||
});
|
||||
return apiError("Too many attempts. Please wait 15 minutes.", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
export type ProofOutcome = { ok: true } | { ok: false; reason: string };
|
||||
|
||||
/**
|
||||
* Verify a proof against the account. Never throws for a bad proof: a
|
||||
* malformed or expired WebAuthn challenge is a failed attempt, not a 500.
|
||||
*
|
||||
* A WebAuthn challenge must belong to this account AND be of the ceremony the
|
||||
* method names, because both ceremony helpers resolve a challenge by id alone.
|
||||
* A TOTP code is accepted only from a confirmed secret (`verifyMfaFactor`).
|
||||
*/
|
||||
export async function verifyExistingFactorProof(
|
||||
user: Pick<
|
||||
User,
|
||||
| "id"
|
||||
| "passwordHash"
|
||||
| "totpSecretEncrypted"
|
||||
| "totpLastStep"
|
||||
| "totpConfirmedAt"
|
||||
>,
|
||||
proof: ExistingFactorProof,
|
||||
): Promise<ProofOutcome> {
|
||||
if (proof.method === "password") {
|
||||
if (!user.passwordHash) return { ok: false, reason: "no_password" };
|
||||
const ok = await verifyPassword(user.passwordHash, proof.password);
|
||||
return ok ? { ok } : { ok: false, reason: "bad_password" };
|
||||
}
|
||||
|
||||
if (proof.method === "totp") {
|
||||
const result = await verifyMfaFactor(user, "totp", proof.code);
|
||||
if (result.ok) return { ok: true };
|
||||
return { ok: false, reason: result.replay ? "totp_replay" : "bad_totp" };
|
||||
}
|
||||
|
||||
const expectedType =
|
||||
proof.method === "passkey" ? "authentication" : "mfa_authentication";
|
||||
const challenge = await prisma.authChallenge.findUnique({
|
||||
where: { id: proof.challengeId },
|
||||
select: { userId: true, type: true },
|
||||
});
|
||||
if (!challenge || challenge.userId !== user.id) {
|
||||
return { ok: false, reason: "foreign_challenge" };
|
||||
}
|
||||
if (challenge.type !== expectedType) {
|
||||
return { ok: false, reason: "wrong_ceremony" };
|
||||
}
|
||||
|
||||
try {
|
||||
if (proof.method === "passkey") {
|
||||
const result = await verifyAuthentication(
|
||||
proof.challengeId,
|
||||
proof.credential,
|
||||
);
|
||||
// Both halves matter: a verified assertion against SOMEONE ELSE'S passkey
|
||||
// proves possession of a factor, just not of this account's.
|
||||
const ok =
|
||||
result.verification.verified && result.passkey.userId === user.id;
|
||||
return ok ? { ok } : { ok: false, reason: "bad_assertion" };
|
||||
}
|
||||
const ok = await verifyMfaAuthentication(
|
||||
proof.challengeId,
|
||||
user.id,
|
||||
proof.credential,
|
||||
);
|
||||
return ok ? { ok } : { ok: false, reason: "bad_assertion" };
|
||||
} catch {
|
||||
return { ok: false, reason: "bad_assertion" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a refused proof. The machine reason goes to the audit row and the
|
||||
* wide event; the wire carries none of it.
|
||||
*/
|
||||
export async function recordReproofFailure(
|
||||
userId: string,
|
||||
ipAddress: string | null,
|
||||
stage: string,
|
||||
method: ExistingFactorProof["method"],
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
await auditLog("auth.mfa.failed", {
|
||||
userId,
|
||||
ipAddress,
|
||||
details: { stage, method, reason },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.reproof.failed" },
|
||||
meta: { stage, method, reason },
|
||||
});
|
||||
}
|
||||
|
||||
/** No proof was presented, and the session carries none recent enough. */
|
||||
export const REPROOF_REQUIRED_CODE = "auth.reproof.required";
|
||||
/** A proof was presented and did not verify. The session is untouched. */
|
||||
export const REPROOF_FAILED_CODE = "auth.reproof.failed";
|
||||
|
||||
/**
|
||||
* How recent a sign-in or a second-factor stamp must be to stand in for a
|
||||
* proof in the body. The step-up window, so a fresh login is exactly as good
|
||||
* here as it is on the fresh-factor routes.
|
||||
*/
|
||||
export const RECENT_PROOF_MAX_AGE_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The cookie-path gate in front of ADDING a sign-in credential or second
|
||||
* factor to an account (TOTP enrollment, security-key enrollment).
|
||||
*
|
||||
* A live session on its own is not enough. If it were, whoever held a stolen
|
||||
* session could enroll their own authenticator, and from then on hold a factor
|
||||
* that satisfies every step-up gate, registers a passkey and survives the
|
||||
* owner changing the password. So the caller must show that the person at the
|
||||
* keyboard can authenticate as the account owner right now, in one of three
|
||||
* ways:
|
||||
*
|
||||
* - the session completed a second factor or passkey sign-in within the last
|
||||
* five minutes (`Session.mfaVerifiedAt`);
|
||||
* - the session itself was created by a sign-in within the last five
|
||||
* minutes (`Session.createdAt`, which no later write touches) — this is
|
||||
* also what lets an SSO-only account with nothing else to re-prove enroll
|
||||
* right after signing in;
|
||||
* - the body carries a proof: `{ method: "password", password }`,
|
||||
* `{ method: "totp", code }` or a passkey / security-key assertion, the
|
||||
* same shapes the step-up mint accepts.
|
||||
*
|
||||
* Returns the refusal to send, or null to proceed. Proofs in the body are
|
||||
* throttled and audited through the shared re-proof bucket.
|
||||
*/
|
||||
export async function checkCookieEnrollmentProof(args: {
|
||||
user: User;
|
||||
sessionId: string;
|
||||
request: Request;
|
||||
ipAddress: string | null;
|
||||
stage: string;
|
||||
}): Promise<Response | null> {
|
||||
const { user, sessionId, request, ipAddress, stage } = args;
|
||||
|
||||
const row = await prisma.session.findUnique({
|
||||
where: { id: sessionId },
|
||||
select: { mfaVerifiedAt: true, createdAt: true },
|
||||
});
|
||||
const now = Date.now();
|
||||
const recent = (d: Date | null | undefined) =>
|
||||
d != null && now - d.getTime() <= RECENT_PROOF_MAX_AGE_MS;
|
||||
if (recent(row?.mfaVerifiedAt) || recent(row?.createdAt)) {
|
||||
annotate({ meta: { enrollment_proof: "recent_session" } });
|
||||
return null;
|
||||
}
|
||||
|
||||
const proof = await readProof(request);
|
||||
if (proof === "absent") {
|
||||
annotate({ action: { name: "auth.reproof.required" }, meta: { stage } });
|
||||
return apiError("Confirm it is you before adding a sign-in method", 401, {
|
||||
errorCode: REPROOF_REQUIRED_CODE,
|
||||
});
|
||||
}
|
||||
if (proof === "invalid") {
|
||||
return apiError("Invalid verification", 422, {
|
||||
errorCode: REPROOF_REQUIRED_CODE,
|
||||
});
|
||||
}
|
||||
|
||||
const limited = await throttleReproof(user.id, ipAddress, stage);
|
||||
if (limited) return limited;
|
||||
|
||||
const outcome = await verifyExistingFactorProof(user, proof);
|
||||
if (!outcome.ok) {
|
||||
await recordReproofFailure(
|
||||
user.id,
|
||||
ipAddress,
|
||||
stage,
|
||||
proof.method,
|
||||
outcome.reason,
|
||||
);
|
||||
return apiError("Verification failed", 401, {
|
||||
errorCode: REPROOF_FAILED_CODE,
|
||||
});
|
||||
}
|
||||
annotate({ meta: { enrollment_proof: proof.method } });
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readProof(
|
||||
request: Request,
|
||||
): Promise<ExistingFactorProof | "absent" | "invalid"> {
|
||||
if (!request.headers.get("content-type")?.includes("application/json")) {
|
||||
return "absent";
|
||||
}
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await request.text();
|
||||
} catch {
|
||||
return "invalid";
|
||||
}
|
||||
if (raw.trim() === "") return "absent";
|
||||
if (raw.length > 64 * 1024) return "invalid";
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(raw);
|
||||
} catch {
|
||||
return "invalid";
|
||||
}
|
||||
if (
|
||||
body === null ||
|
||||
(typeof body === "object" && Object.keys(body).length === 0)
|
||||
) {
|
||||
return "absent";
|
||||
}
|
||||
const parsed = stepUpMintSchema.safeParse(body);
|
||||
return parsed.success ? parsed.data : "invalid";
|
||||
}
|
||||
|
||||
export type SensitiveChangeProof =
|
||||
"ok" | "required" | "failed" | "rate_limited";
|
||||
|
||||
/**
|
||||
* The proof in front of changing the account's email address.
|
||||
*
|
||||
* The address is what single sign-on matches an existing account by, so it is
|
||||
* part of how the account is reached and a stolen session must not rewrite it.
|
||||
* Accepted: on a cookie session, a sign-in or second factor inside the last
|
||||
* five minutes; on either transport, the current password alongside the
|
||||
* change. The password draws on the shared re-proof budget and a wrong one is
|
||||
* audited like every other refused proof.
|
||||
*/
|
||||
export async function authorizeSensitiveChange(args: {
|
||||
user: Pick<
|
||||
User,
|
||||
| "id"
|
||||
| "passwordHash"
|
||||
| "totpSecretEncrypted"
|
||||
| "totpLastStep"
|
||||
| "totpConfirmedAt"
|
||||
>;
|
||||
/** The session row id on a cookie request; null on a Bearer request. */
|
||||
cookieSessionId: string | null;
|
||||
currentPassword: string | null;
|
||||
ipAddress: string | null;
|
||||
stage: string;
|
||||
}): Promise<SensitiveChangeProof> {
|
||||
const { user, cookieSessionId, currentPassword, ipAddress, stage } = args;
|
||||
|
||||
if (cookieSessionId) {
|
||||
const row = await prisma.session.findUnique({
|
||||
where: { id: cookieSessionId },
|
||||
select: { mfaVerifiedAt: true, createdAt: true },
|
||||
});
|
||||
const now = Date.now();
|
||||
const recent = (d: Date | null | undefined) =>
|
||||
d != null && now - d.getTime() <= RECENT_PROOF_MAX_AGE_MS;
|
||||
if (recent(row?.mfaVerifiedAt) || recent(row?.createdAt)) return "ok";
|
||||
}
|
||||
|
||||
if (!currentPassword) return "required";
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
reproofBucket(user.id),
|
||||
REPROOF_LIMIT,
|
||||
REPROOF_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
await auditLog("auth.reproof.rate_limited", {
|
||||
userId: user.id,
|
||||
ipAddress,
|
||||
details: { stage },
|
||||
});
|
||||
return "rate_limited";
|
||||
}
|
||||
|
||||
const outcome = await verifyExistingFactorProof(user, {
|
||||
method: "password",
|
||||
password: currentPassword,
|
||||
});
|
||||
if (!outcome.ok) {
|
||||
await recordReproofFailure(
|
||||
user.id,
|
||||
ipAddress,
|
||||
stage,
|
||||
"password",
|
||||
outcome.reason,
|
||||
);
|
||||
return "failed";
|
||||
}
|
||||
return "ok";
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
|
||||
import { recordSignInDevice } from "@/lib/auth/login-alert";
|
||||
import { detectInsecureCookieTransport } from "@/lib/auth/secure-cookie";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { completePendingOidcLink } from "@/lib/auth/oidc-pending-link";
|
||||
import type { User } from "@/generated/prisma/client";
|
||||
|
||||
export interface FinishLoginParams {
|
||||
@@ -130,6 +131,13 @@ export async function finishLogin(
|
||||
annotate({ meta: { session_cookie_secure_transport_mismatch: true } });
|
||||
}
|
||||
|
||||
// A browser sign-in that proved the account's own credential confirms a
|
||||
// single sign-on link the callback left pending for this very account. An
|
||||
// SSO sign-in is not such a proof, so it never completes one.
|
||||
if (!source.startsWith("login.oidc") && source !== "login.web_handoff") {
|
||||
await completePendingOidcLink(user.id, ip);
|
||||
}
|
||||
|
||||
// `createSession` anchors the onboarding cookie itself; thread the user's
|
||||
// onboarding state through.
|
||||
await createSession(
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
createMfaChallenge,
|
||||
loadActiveChallenge,
|
||||
recordChallengeFailure,
|
||||
reserveChallengeAttempt,
|
||||
claimChallenge,
|
||||
MFA_CHALLENGE_ATTEMPT_CAP,
|
||||
} from "../challenge";
|
||||
@@ -91,8 +92,41 @@ describe("mfa challenge", () => {
|
||||
expect(await loadActiveChallenge("nope")).toBeNull();
|
||||
});
|
||||
|
||||
it("reserves an attempt with one guarded update bounded by the cap", async () => {
|
||||
vi.mocked(prisma.mfaChallenge.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
expect(await reserveChallengeAttempt("ch-1")).toBe(true);
|
||||
expect(prisma.mfaChallenge.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "ch-1",
|
||||
consumedAt: null,
|
||||
attempts: { lt: MFA_CHALLENGE_ATTEMPT_CAP },
|
||||
expiresAt: { gt: expect.any(Date) },
|
||||
},
|
||||
data: { attempts: { increment: 1 } },
|
||||
});
|
||||
// No separate read decides the cap — the WHERE is the whole check.
|
||||
expect(prisma.mfaChallenge.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses the reservation once the guarded update matches nothing", async () => {
|
||||
vi.mocked(prisma.mfaChallenge.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
expect(await reserveChallengeAttempt("ch-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("settling a failure does not count the attempt a second time", async () => {
|
||||
vi.mocked(prisma.mfaChallenge.findUnique).mockResolvedValue({
|
||||
attempts: 2,
|
||||
} as never);
|
||||
await recordChallengeFailure("ch-1");
|
||||
expect(prisma.mfaChallenge.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("burns the ticket when the attempt cap is reached", async () => {
|
||||
vi.mocked(prisma.mfaChallenge.update).mockResolvedValue({
|
||||
vi.mocked(prisma.mfaChallenge.findUnique).mockResolvedValue({
|
||||
attempts: MFA_CHALLENGE_ATTEMPT_CAP,
|
||||
} as never);
|
||||
vi.mocked(prisma.mfaChallenge.updateMany).mockResolvedValue({
|
||||
@@ -108,7 +142,7 @@ describe("mfa challenge", () => {
|
||||
});
|
||||
|
||||
it("does not burn below the attempt cap", async () => {
|
||||
vi.mocked(prisma.mfaChallenge.update).mockResolvedValue({
|
||||
vi.mocked(prisma.mfaChallenge.findUnique).mockResolvedValue({
|
||||
attempts: 1,
|
||||
} as never);
|
||||
const res = await recordChallengeFailure("ch-1");
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* `verifyMfaFactor` is the one place a TOTP code is accepted as proof of a
|
||||
* second factor — login completion, step-up minting, MFA disable and the
|
||||
* passkey-enrollment re-proof all call it. A secret that was never confirmed
|
||||
* is a pending enrollment, not a factor: whoever holds a session can create
|
||||
* one, so a code from it proves only that the caller started enrollment.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { updateMany: vi.fn() } },
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn(() => "SECRET"),
|
||||
}));
|
||||
vi.mock("@/lib/auth/mfa/totp", () => ({
|
||||
verifyTotp: vi.fn(() => ({ valid: true, replay: false, step: 100 })),
|
||||
}));
|
||||
vi.mock("@/lib/auth/mfa/recovery-codes", () => ({
|
||||
verifyAndConsumeRecoveryCode: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
import { verifyMfaFactor } from "../verify-factor";
|
||||
import { verifyTotp } from "@/lib/auth/mfa/totp";
|
||||
import { verifyAndConsumeRecoveryCode } from "@/lib/auth/mfa/recovery-codes";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const baseUser = {
|
||||
id: "u1",
|
||||
totpSecretEncrypted: "enc",
|
||||
totpLastStep: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
||||
});
|
||||
|
||||
describe("verifyMfaFactor", () => {
|
||||
it("accepts a valid code against a confirmed secret and burns the step", async () => {
|
||||
const res = await verifyMfaFactor(
|
||||
{ ...baseUser, totpConfirmedAt: new Date() },
|
||||
"totp",
|
||||
"123456",
|
||||
);
|
||||
expect(res).toEqual({ ok: true, replay: false });
|
||||
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refuses a valid code against a pending (unconfirmed) secret", async () => {
|
||||
const res = await verifyMfaFactor(
|
||||
{ ...baseUser, totpConfirmedAt: null },
|
||||
"totp",
|
||||
"123456",
|
||||
);
|
||||
expect(res).toEqual({ ok: false, replay: false });
|
||||
// The code is never even evaluated, and no step is burned.
|
||||
expect(verifyTotp).not.toHaveBeenCalled();
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses when no secret is stored at all", async () => {
|
||||
const res = await verifyMfaFactor(
|
||||
{ ...baseUser, totpSecretEncrypted: null, totpConfirmedAt: new Date() },
|
||||
"totp",
|
||||
"123456",
|
||||
);
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a recovery code while no second factor is confirmed", async () => {
|
||||
const res = await verifyMfaFactor(
|
||||
{ ...baseUser, totpConfirmedAt: null },
|
||||
"recovery",
|
||||
"abcd-efgh",
|
||||
);
|
||||
expect(res.ok).toBe(false);
|
||||
expect(verifyAndConsumeRecoveryCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a recovery code once the factor is confirmed", async () => {
|
||||
const res = await verifyMfaFactor(
|
||||
{ ...baseUser, totpConfirmedAt: new Date() },
|
||||
"recovery",
|
||||
"abcd-efgh",
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -11,9 +11,13 @@
|
||||
*
|
||||
* Guarantees enforced here:
|
||||
* - **TTL** (~5 min) — `expiresAt`; an expired ticket is never loadable.
|
||||
* - **Attempt cap** — `attempts` is incremented on every wrong factor and
|
||||
* the ticket is burned (`consumedAt` set) once the cap is hit, forcing a
|
||||
* fresh password login (NIST throttle, not an account lock).
|
||||
* - **Attempt cap** — every verification first RESERVES an attempt with one
|
||||
* guarded UPDATE (`attempts < cap` in the WHERE), so the cap bounds the
|
||||
* number of guesses even when they arrive concurrently. It used to be a
|
||||
* read, a verification, then an increment: parallel requests all read the
|
||||
* same count below the cap and every one of them got to guess. A failed
|
||||
* verification then burns the ticket (`consumedAt` set) once the cap is
|
||||
* hit, forcing a fresh password login (NIST throttle, not an account lock).
|
||||
* - **Claim-once** — consuming a ticket is an atomic guarded update
|
||||
* (`consumedAt: null` in the WHERE), so two concurrent verifications can
|
||||
* never both succeed and mint two sessions. The factor is verified first;
|
||||
@@ -96,26 +100,50 @@ export async function loadActiveChallenge(
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failed factor attempt. Increments `attempts`; when the cap is
|
||||
* reached the ticket is burned (`consumedAt` set) so it cannot be retried.
|
||||
* Returns whether the ticket is now exhausted.
|
||||
* Reserve one verification attempt against the ticket. Must run BEFORE the
|
||||
* factor is checked. The increment and the cap check are one conditional
|
||||
* UPDATE, so of any number of concurrent callers at most
|
||||
* `MFA_CHALLENGE_ATTEMPT_CAP` in total ever get `true`. A `false` means the
|
||||
* ticket is spent, expired or out of attempts, and the caller answers with the
|
||||
* same generic refusal as an unknown ticket.
|
||||
*/
|
||||
export async function reserveChallengeAttempt(
|
||||
challengeId: string,
|
||||
): Promise<boolean> {
|
||||
const reserved = await prisma.mfaChallenge.updateMany({
|
||||
where: {
|
||||
id: challengeId,
|
||||
consumedAt: null,
|
||||
attempts: { lt: MFA_CHALLENGE_ATTEMPT_CAP },
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
data: { attempts: { increment: 1 } },
|
||||
});
|
||||
return reserved.count === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle a failed attempt that `reserveChallengeAttempt` already counted.
|
||||
* Does not increment again; when the reserved count has reached the cap the
|
||||
* ticket is burned (`consumedAt` set) so it cannot be retried. Returns whether
|
||||
* the ticket is now exhausted.
|
||||
*/
|
||||
export async function recordChallengeFailure(
|
||||
challengeId: string,
|
||||
): Promise<{ exhausted: boolean; attempts: number }> {
|
||||
const updated = await prisma.mfaChallenge.update({
|
||||
const row = await prisma.mfaChallenge.findUnique({
|
||||
where: { id: challengeId },
|
||||
data: { attempts: { increment: 1 } },
|
||||
select: { attempts: true },
|
||||
});
|
||||
const exhausted = updated.attempts >= MFA_CHALLENGE_ATTEMPT_CAP;
|
||||
const attempts = row?.attempts ?? MFA_CHALLENGE_ATTEMPT_CAP;
|
||||
const exhausted = attempts >= MFA_CHALLENGE_ATTEMPT_CAP;
|
||||
if (exhausted) {
|
||||
await prisma.mfaChallenge.updateMany({
|
||||
where: { id: challengeId, consumedAt: null },
|
||||
data: { consumedAt: new Date() },
|
||||
});
|
||||
}
|
||||
return { exhausted, attempts: updated.attempts };
|
||||
return { exhausted, attempts };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,9 +8,18 @@
|
||||
* - **Recovery code:** Argon2id-verify against the unused set and burn the
|
||||
* matched row.
|
||||
*
|
||||
* Used by both the login-completion route (`/api/auth/mfa/verify`) and the
|
||||
* step-up-gated MFA-disable route, so the factor semantics are identical
|
||||
* wherever a current factor must be proven.
|
||||
* Used by the login-completion route (`/api/auth/mfa/verify`), the Bearer
|
||||
* step-up mint, the step-up-gated MFA-disable route and the credential
|
||||
* re-proof in `existing-factor-proof.ts`, so the factor semantics are
|
||||
* identical wherever a current factor must be proven.
|
||||
*
|
||||
* **Only a CONFIRMED secret is a factor.** `/me/mfa/totp/setup` writes a
|
||||
* pending secret for anyone holding the session, so a code from a secret
|
||||
* whose `totpConfirmedAt` is still null proves only that the caller started
|
||||
* an enrollment — never that they hold a factor the account owner set up.
|
||||
* Accepting it would let a stolen session mint its own "second factor" and
|
||||
* spend it on every gate this function guards. Recovery codes follow the same
|
||||
* rule: they exist only for a confirmed factor, and are refused otherwise.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
@@ -23,6 +32,8 @@ export interface FactorUser {
|
||||
id: string;
|
||||
totpSecretEncrypted: string | null;
|
||||
totpLastStep: bigint | null;
|
||||
/** Null while the secret is a pending enrollment — not yet a factor. */
|
||||
totpConfirmedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface FactorResult {
|
||||
@@ -36,6 +47,10 @@ export async function verifyMfaFactor(
|
||||
method: MfaMethod,
|
||||
code: string,
|
||||
): Promise<FactorResult> {
|
||||
// A pending secret is not a factor, and recovery codes only ever belong to a
|
||||
// confirmed one. Refuse both before any code is evaluated or step burned.
|
||||
if (!user.totpConfirmedAt) return { ok: false, replay: false };
|
||||
|
||||
if (method === "recovery") {
|
||||
const ok = await verifyAndConsumeRecoveryCode(user.id, code);
|
||||
return { ok, replay: false };
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Linking a single sign-on identity to an account that already exists here.
|
||||
*
|
||||
* The SSO callback used to bind a verified IdP email to the local account with
|
||||
* the same address on the spot. The local address is NOT verified — anyone can
|
||||
* register with any address, and an account holder can type any address into
|
||||
* their profile — so whoever set that address first owned the account the
|
||||
* other person's SSO login landed in: they kept the password, and the SSO user
|
||||
* filled their health record into an account the other person could read.
|
||||
*
|
||||
* So the callback no longer links. It records what it WOULD link in a sealed,
|
||||
* short-lived, HttpOnly cookie and sends the browser to the sign-in page, and
|
||||
* the link is made only when that same browser then signs in to that same
|
||||
* account with the account's own credential — password (plus its second
|
||||
* factor, if it has one) or passkey. The IdP proved who the person is; the
|
||||
* local sign-in proves the account is theirs. Neither alone links anything.
|
||||
*
|
||||
* The cookie is AES-256-GCM sealed with the at-rest key (`encrypt`), so it can
|
||||
* be neither read nor forged, and it carries its own expiry. It is scoped to
|
||||
* `/api/auth`, the only place that reads it.
|
||||
*/
|
||||
import type { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
|
||||
export const OIDC_LINK_COOKIE = "hl_oidc_link";
|
||||
export const OIDC_LINK_COOKIE_PATH = "/api/auth";
|
||||
export const OIDC_LINK_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
export interface PendingOidcLink {
|
||||
userId: string;
|
||||
issuer: string;
|
||||
sub: string;
|
||||
}
|
||||
|
||||
/** Attach the sealed pending link to a callback redirect. */
|
||||
export function setPendingLinkCookie(
|
||||
response: NextResponse,
|
||||
link: PendingOidcLink,
|
||||
): void {
|
||||
const sealed = encrypt(
|
||||
JSON.stringify({
|
||||
u: link.userId,
|
||||
i: link.issuer,
|
||||
s: link.sub,
|
||||
e: Date.now() + OIDC_LINK_TTL_MS,
|
||||
}),
|
||||
);
|
||||
response.cookies.set(OIDC_LINK_COOKIE, sealed, {
|
||||
httpOnly: true,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
// Lax, not Strict: the browser arrives here from the IdP's redirect and
|
||||
// must carry it on the sign-in that follows. A cross-site POST cannot
|
||||
// carry it, and every reader is a JSON POST besides.
|
||||
sameSite: "lax",
|
||||
maxAge: Math.floor(OIDC_LINK_TTL_MS / 1000),
|
||||
path: OIDC_LINK_COOKIE_PATH,
|
||||
});
|
||||
}
|
||||
|
||||
/** The pending link this browser carries, or null when absent, forged or expired. */
|
||||
export async function readPendingLink(): Promise<PendingOidcLink | null> {
|
||||
let raw: string | undefined;
|
||||
try {
|
||||
raw = (await cookies()).get(OIDC_LINK_COOKIE)?.value;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(decrypt(raw)) as Record<string, unknown>;
|
||||
if (
|
||||
typeof parsed.u !== "string" ||
|
||||
typeof parsed.i !== "string" ||
|
||||
typeof parsed.s !== "string" ||
|
||||
typeof parsed.e !== "number" ||
|
||||
parsed.e <= Date.now()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { userId: parsed.u, issuer: parsed.i, sub: parsed.s };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearPendingLinkCookie(): Promise<void> {
|
||||
try {
|
||||
(await cookies()).set(OIDC_LINK_COOKIE, "", {
|
||||
httpOnly: true,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
sameSite: "lax",
|
||||
maxAge: 0,
|
||||
path: OIDC_LINK_COOKIE_PATH,
|
||||
});
|
||||
} catch {
|
||||
// Outside a request scope there is no cookie to clear.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called once a browser sign-in has proved the account's OWN credential.
|
||||
* Binds the pending SSO identity when the cookie names this very account and
|
||||
* the account is still unlinked. Never throws into the sign-in: a link that
|
||||
* cannot be made leaves the login intact and the account as it was.
|
||||
*/
|
||||
export async function completePendingOidcLink(
|
||||
userId: string,
|
||||
ipAddress: string | null,
|
||||
): Promise<boolean> {
|
||||
const link = await readPendingLink();
|
||||
if (!link) return false;
|
||||
await clearPendingLinkCookie();
|
||||
if (link.userId !== userId) {
|
||||
annotate({ meta: { oidc_link: "other_account" } });
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const linked = await prisma.user.updateMany({
|
||||
where: { id: userId, oidcIssuer: null, oidcSub: null },
|
||||
data: { oidcIssuer: link.issuer, oidcSub: link.sub },
|
||||
});
|
||||
if (linked.count !== 1) {
|
||||
annotate({ meta: { oidc_link: "already_linked" } });
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// The (issuer, sub) pair became bound to another account in between.
|
||||
annotate({ meta: { oidc_link: "identity_taken" } });
|
||||
return false;
|
||||
}
|
||||
await auditLog("auth.oidc.linked", {
|
||||
userId,
|
||||
ipAddress,
|
||||
details: { confirmedBy: "local_sign_in" },
|
||||
});
|
||||
annotate({ action: { name: "auth.oidc.linked" } });
|
||||
return true;
|
||||
}
|
||||
@@ -16,6 +16,11 @@ import { sanitiseZodIssues, type SanitisedZodIssue } from "@/lib/api-response";
|
||||
import { checkProfileEmailRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
REPROOF_FAILED_CODE,
|
||||
REPROOF_REQUIRED_CODE,
|
||||
type SensitiveChangeProof,
|
||||
} from "@/lib/auth/existing-factor-proof";
|
||||
|
||||
const extendedProfileSchema = profileSchema.extend({
|
||||
displayName: z.string().min(1).max(80).nullable().optional(),
|
||||
@@ -185,7 +190,19 @@ export interface ApplyProfileError {
|
||||
export async function applyProfileUpdate(
|
||||
userId: string,
|
||||
body: unknown,
|
||||
ipAddress?: string | null,
|
||||
ipAddress: string | null | undefined,
|
||||
opts: {
|
||||
/**
|
||||
* Decides whether this request may move the account to a NEW email
|
||||
* address. The address is what single sign-on links an existing account
|
||||
* by, so changing it asks for a fresh proof (`authorizeSensitiveChange`).
|
||||
* Receives the `currentPassword` the body carried, if any; the password is
|
||||
* never written anywhere.
|
||||
*/
|
||||
authorizeEmailChange: (
|
||||
currentPassword: string | null,
|
||||
) => Promise<SensitiveChangeProof>;
|
||||
},
|
||||
): Promise<ApplyProfileResult | ApplyProfileError> {
|
||||
const parsed = extendedProfileSchema.safeParse(body);
|
||||
|
||||
@@ -258,6 +275,66 @@ export async function applyProfileUpdate(
|
||||
const addressOnFile = current?.email?.trim().toLowerCase() ?? null;
|
||||
|
||||
if (addressOnFile !== normalizedEmail) {
|
||||
// Asked before the uniqueness probe and before the budget: a caller who
|
||||
// cannot show it is the account owner learns nothing about which
|
||||
// addresses exist here and spends nothing.
|
||||
const rawPassword =
|
||||
typeof body === "object" && body !== null && !Array.isArray(body)
|
||||
? (body as Record<string, unknown>).currentPassword
|
||||
: undefined;
|
||||
const currentPassword =
|
||||
typeof rawPassword === "string" &&
|
||||
rawPassword.length > 0 &&
|
||||
rawPassword.length <= 512
|
||||
? rawPassword
|
||||
: null;
|
||||
const proof = await opts.authorizeEmailChange(currentPassword);
|
||||
if (proof !== "ok") {
|
||||
annotate({
|
||||
action: { name: "profile.email.reproof_refused" },
|
||||
meta: { proof },
|
||||
});
|
||||
const code =
|
||||
proof === "rate_limited"
|
||||
? "rate_limited"
|
||||
: proof === "failed"
|
||||
? "reproof_failed"
|
||||
: "reproof_required";
|
||||
// Same narrowing as the email budget below: the address is dropped,
|
||||
// the rest of the save lands, and `rejectedFields` says why.
|
||||
const { email: _unproven, ...keepable } = data;
|
||||
if (Object.keys(keepable).length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
status: proof === "rate_limited" ? 429 : 401,
|
||||
message:
|
||||
proof === "failed"
|
||||
? "Verification failed"
|
||||
: proof === "rate_limited"
|
||||
? "Too many attempts. Please wait 15 minutes."
|
||||
: "Confirm your current password to change the email address.",
|
||||
errorCode:
|
||||
proof === "failed"
|
||||
? REPROOF_FAILED_CODE
|
||||
: proof === "rate_limited"
|
||||
? "profile.update.emailRateLimited"
|
||||
: REPROOF_REQUIRED_CODE,
|
||||
};
|
||||
}
|
||||
data = keepable;
|
||||
rejectedFields = [
|
||||
...rejectedFields,
|
||||
{
|
||||
path: "email",
|
||||
code,
|
||||
message:
|
||||
"Confirm your current password to change the email address.",
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (data.email !== undefined && addressOnFile !== normalizedEmail) {
|
||||
// Charged BEFORE the probe, not after: a refused request must not
|
||||
// learn the answer it was refused for. The 409 below tells any
|
||||
// signed-in caller whether an address is registered here, and until
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Upload routes that do NOT pass through `src/proxy.ts`, and the edge duties
|
||||
* `apiHandler` takes over for them.
|
||||
*
|
||||
* WHY. Next clones the body of every non-GET request the proxy matcher covers
|
||||
* so both the proxy and the route can read it, and it reads that clone off the
|
||||
* socket into memory up to `experimental.proxyClientMaxBodySize` before the
|
||||
* route — and therefore authentication — runs at all. The ceiling was 512 MB
|
||||
* so the Apple Health importer could receive whole archives, which meant any
|
||||
* anonymous POST to any API path could make the server hold half a gigabyte
|
||||
* (twice, one copy per branch of the clone). The ceiling is now 1 MB, and the
|
||||
* routes whose legitimate bodies are larger are taken out of the matcher: they
|
||||
* stream or bound their own body after authenticating, so nothing reads their
|
||||
* body before the caller is known.
|
||||
*
|
||||
* WHAT THE PROXY DID FOR THEM, AND WHO DOES IT NOW. For an `/api/*` route the
|
||||
* proxy refuses traffic in a worker-only container, refuses every mutation on
|
||||
* a demo instance (none of these routes is on the demo allowlist), sets the
|
||||
* transport security headers and a CSP, and echoes `x-request-id`. `apiHandler`
|
||||
* does all of that for the routes listed here (`bypassRouteRefusal`,
|
||||
* `applyBypassRouteHeaders`); it already echoes `x-request-id` for every route.
|
||||
* The legacy redirects, the retired-route 410s and the page session gate never
|
||||
* applied to these paths.
|
||||
*
|
||||
* ONE LIST, TWO READERS. The matcher in `src/proxy.ts` must be a literal Next
|
||||
* can analyse at build time, so it cannot import this list; a test compiles the
|
||||
* matcher exactly as Next does and checks it excludes every path here and
|
||||
* nothing else under `/api`.
|
||||
*/
|
||||
import { shouldRunWeb } from "@/lib/process-type";
|
||||
|
||||
export const PROXY_BYPASS_ROUTES: readonly string[] = [
|
||||
// Streamed archive uploads, up to 1.5 GB.
|
||||
"/api/import/apple-health-export",
|
||||
"/api/admin/import-apple-health-export",
|
||||
// Backup file upload, up to 512 MB raw.
|
||||
"/api/admin/backups/upload",
|
||||
// Document vault upload, admin-tunable per-file cap.
|
||||
"/api/documents/inbound",
|
||||
// Bulk JSON / CSV imports, 16 MB.
|
||||
"/api/import",
|
||||
"/api/import/csv",
|
||||
"/api/medications/intake/dose-history-import",
|
||||
// Lab scan upload, 12 MB.
|
||||
"/api/labs/ocr/extract",
|
||||
// Avatar image, 2 MB.
|
||||
"/api/user/avatar",
|
||||
// Native-client batches: measurements 4 MB, workouts 5 MB, the rest 2 MB.
|
||||
"/api/measurements/batch",
|
||||
"/api/workouts/batch",
|
||||
"/api/insights/ecg",
|
||||
"/api/mood-entries/bulk",
|
||||
"/api/medications/intake/bulk",
|
||||
"/api/cycle/day-logs/bulk",
|
||||
];
|
||||
|
||||
export function isProxyBypassRoute(pathname: string): boolean {
|
||||
const normalised =
|
||||
pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
|
||||
return PROXY_BYPASS_ROUTES.includes(normalised);
|
||||
}
|
||||
|
||||
/**
|
||||
* The transport security headers the proxy puts on every response. Shared so
|
||||
* the proxy and the bypass routes cannot drift apart.
|
||||
*/
|
||||
export function setBaselineSecurityHeaders(headers: Headers): void {
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
headers.set("X-Frame-Options", "DENY");
|
||||
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
||||
headers.set("Cross-Origin-Opener-Policy", "same-origin");
|
||||
headers.set("X-Permitted-Cross-Domain-Policies", "none");
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
headers.set(
|
||||
"Strict-Transport-Security",
|
||||
"max-age=31536000; includeSubDomains; preload",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The CSP for a bypass route's response. These answer JSON, never a page, so
|
||||
* the response may load nothing and be framed by nobody.
|
||||
*/
|
||||
export const BYPASS_ROUTE_CSP =
|
||||
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'";
|
||||
|
||||
export function applyBypassRouteHeaders(headers: Headers): void {
|
||||
setBaselineSecurityHeaders(headers);
|
||||
headers.set("Content-Security-Policy", BYPASS_ROUTE_CSP);
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusals the proxy would have answered with, in the same shape.
|
||||
* Returns null when the request may proceed.
|
||||
*/
|
||||
export function bypassRouteRefusal(method: string): Response | null {
|
||||
if (!shouldRunWeb()) {
|
||||
return Response.json(
|
||||
{
|
||||
data: null,
|
||||
error:
|
||||
"This container runs the worker only — point HTTP at the web service.",
|
||||
},
|
||||
{ status: 503, headers: { "X-HealthLog-Process-Type": "worker" } },
|
||||
);
|
||||
}
|
||||
const m = method.toUpperCase();
|
||||
const isMutation = m !== "GET" && m !== "HEAD" && m !== "OPTIONS";
|
||||
if (process.env.DEMO_MODE === "true" && isMutation) {
|
||||
return Response.json(
|
||||
{
|
||||
data: null,
|
||||
error: "Demo mode: modifications are disabled",
|
||||
meta: { demo: true },
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -60,6 +60,8 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"auth.refresh.invalid",
|
||||
"auth.refresh.reuse",
|
||||
"auth.refresh.revoked",
|
||||
"auth.reproof.failed",
|
||||
"auth.reproof.required",
|
||||
"auth.scope.insufficient",
|
||||
"auth.stepup.required",
|
||||
"auth.token.expired",
|
||||
|
||||
@@ -315,6 +315,17 @@ const successFlagResponse = z
|
||||
const MFA_MANAGEMENT_AUTH_NOTE =
|
||||
" Accepts a cookie session, or a Bearer token presenting a single-use elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused.";
|
||||
|
||||
const ENROLLMENT_PROOF_NOTE =
|
||||
"\n\nAdding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof (a password-proved one is enough). On the cookie path the request passes when the session signed in, or completed a second factor, within the last five minutes; otherwise the body must carry a proof in the shape POST /api/auth/step-up takes (`password`, a `totp` code from an already confirmed authenticator, or a passkey / security-key assertion begun at POST /api/auth/passkey/register-options). Without one the answer is 401 `auth.reproof.required`; a proof that does not verify is 401 `auth.reproof.failed` and is audited. Proofs draw on the account's shared re-proof budget of five per fifteen minutes (429 once spent), the same budget as the step-up mint.";
|
||||
|
||||
const enrollmentProofResponses = {
|
||||
"401": {
|
||||
description:
|
||||
"No session or elevation; or, on the cookie path, `auth.reproof.required` (no recent sign-in and no proof in the body) or `auth.reproof.failed` (the proof did not verify).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ── v1.30.34 step-up elevation (Bearer transport) ────────────────────
|
||||
|
||||
const stepUpOptionsResponse = z
|
||||
@@ -641,7 +652,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"Password login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`; sign in through SSO instead.",
|
||||
"Password login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`; sign in through SSO instead. The one exception is a browser the SSO callback sent back with a pending link to confirm an existing account: it may sign in with that one account's password, which links the account.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
@@ -805,7 +816,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"Re-prove a factor and receive an opaque elevation that authorises exactly ONE second-factor-management call.\n\n" +
|
||||
"WHICH factor you re-prove decides WHAT the elevation reaches. `password` reaches the same routes a plain cookie session reaches. `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes — MFA disable, recovery-code regeneration, security-key removal — which is precisely the set of ceremonies for which the web marks a session second-factor-verified. The response carries `satisfiesFreshFactor` so a client can choose the right ceremony up front rather than discovering the refusal after spending a proof. A recovery code is NOT accepted here; an account that has lost its authenticator manages its second factor on the web.\n\n" +
|
||||
"The elevation is bound to the exact token that minted it (another token, including the same account's, cannot redeem it), single-use, and valid for five minutes — the same window the cookie path uses. Present it as `X-Step-Up: hle_…` alongside the normal `Authorization: Bearer` header. It is spent only when the target route is about to act, so a 429, a 422, or a wrong code does not burn it.\n\n" +
|
||||
"Presenting the token alone mints nothing: the body must carry a fresh factor proof. Every failure — wrong password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge, a replayed TOTP step — returns the same 401 with the same prose, and is audited server-side. Rate-limited per account (5 / 15 min) and per source address.\n\n" +
|
||||
"Presenting the token alone mints nothing: the body must carry a fresh factor proof. Every failure — wrong password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge, a replayed TOTP step — returns the same 401 with the same prose, and is audited server-side. Rate-limited per source address and per account (5 / 15 min); the per-account budget is shared with the web's credential-enrollment proofs. A `totp` proof counts only from an authenticator whose enrollment was confirmed: a code from a pending secret is refused like a wrong one.\n\n" +
|
||||
"Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE /api/auth/me/mfa/webauthn/{id}; DELETE /api/settings/account; DELETE /api/settings/data. The last three MFA routes, disable, and both erasure routes require a fresh-factor proof. GET /api/auth/me/mfa needs no elevation at all. Nothing else accepts one — admin endpoints stay cookie-only.\n\n" +
|
||||
"The two erasure routes joined the set so an account with a second factor can be deleted from a native app: their gate was cookie-only, which left an enrolled user unable to delete their own account from the app at all. They take the elevation the same way as the rest — fresh factor required, spent only when the erasure is about to run.",
|
||||
requestBody: {
|
||||
@@ -830,8 +841,13 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Auth"],
|
||||
summary: "Begin TOTP enrollment (cookie session or step-up elevation)",
|
||||
description:
|
||||
"Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is not active until /confirm." +
|
||||
MFA_MANAGEMENT_AUTH_NOTE,
|
||||
"Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is not active until /confirm, and a pending secret is not a factor anywhere: a code from it is refused by every route that verifies one." +
|
||||
MFA_MANAGEMENT_AUTH_NOTE +
|
||||
ENROLLMENT_PROOF_NOTE,
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: { "application/json": { schema: stepUpMintSchema } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Pending secret created.",
|
||||
@@ -846,6 +862,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
...enrollmentProofResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -953,7 +970,12 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Begin registering a security key as a second factor",
|
||||
description:
|
||||
"Returns SimpleWebAuthn creation options + a challenge id." +
|
||||
MFA_MANAGEMENT_AUTH_NOTE,
|
||||
MFA_MANAGEMENT_AUTH_NOTE +
|
||||
ENROLLMENT_PROOF_NOTE,
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: { "application/json": { schema: stepUpMintSchema } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Registration options issued.",
|
||||
@@ -967,6 +989,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...enrollmentProofResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1880,6 +1903,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"**Browser arm.** No parameters, or `next=<path>` to return somewhere specific afterwards (sanitised to an in-app path — an absolute URL is discarded, not honoured). Redirects to the provider's authorization endpoint. Every failure redirects to `/auth/login?error=<reason>`.\n\n" +
|
||||
"**Native arm.** The iOS app opens this URL inside an `ASWebAuthenticationSession` with `client=native` and its own PKCE `code_challenge`. The challenge is mandatory on this arm and must be S256, 43–128 characters; `plain` is structurally unsupported because the exchange only ever verifies S256. `next` is ignored and pinned to `/` — a post-login web path means nothing to the app. Every failure redirects to `healthlog://oidc-callback?error=<reason>`, and an error redirect carries no code, no ticket and no session, which is why it is safe that `client` is caller-supplied.\n\n" +
|
||||
"**What comes back.** The provider returns to the server's own callback, not to the app. On success the callback redirects to the compiled-in `healthlog://oidc-callback` with a one-time `code=hlh_…`, which the app exchanges at POST /api/auth/oidc/native/token. When the account has a second factor the callback instead returns `mfa_ticket=…`, which the app completes at /api/auth/mfa/verify to obtain the same bundle. The token pair NEVER rides a URL — only the opaque code or ticket does. There is no `redirect_uri` parameter and no allowlist to configure: the scheme is a compile-time constant, so the open-redirect class is removed by construction.\n\n" +
|
||||
"**An account that exists but is not linked yet.** When the provider's verified email matches a local account with no SSO identity, nothing is linked and nobody is signed in: the local address was never verified, so matching it proves nothing about who owns the account. The browser arm redirects to `/auth/login?error=oidc_link_required` with a sealed, ten-minute, HttpOnly pending-link cookie; the person then signs in to that account once with its own password (and second factor) or passkey, and that sign-in makes the link. Under `OIDC_ONLY=true` the password sign-in is admitted for exactly the account the pending link names. The native arm redirects to `healthlog://oidc-callback?error=oidc_link_required`; the link is confirmed once on the web, after which the app's SSO sign-in works.\n\n" +
|
||||
"Two PKCE exchanges run here and must not be confused: the server↔provider verifier the server generates for itself, and the app↔server challenge supplied above. The native flag and the app's challenge travel only inside an AES-256-GCM state cookie, so the callback branches on tamper-authenticated state rather than on anything the provider or a network attacker can flip.",
|
||||
requestParams: {
|
||||
query: z.object({
|
||||
@@ -1922,7 +1946,8 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
'**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.\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.",
|
||||
"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.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
|
||||
@@ -482,6 +482,13 @@ export const profileUpdateRequest = z
|
||||
.describe(
|
||||
"German insurer institution number (IKNR). Optional; empty/null clears it. A non-empty value must be exactly 9 digits (no checksum enforced).",
|
||||
),
|
||||
currentPassword: z
|
||||
.string()
|
||||
.max(512)
|
||||
.optional()
|
||||
.describe(
|
||||
"The account password, sent only alongside a NEW `email`. Moving the account to a new address needs a fresh proof because single sign-on links an existing account by it: a browser session that signed in or completed a second factor in the last five minutes needs nothing more, otherwise this field must carry the current password. Without an accepted proof the address is not changed and comes back in `rejectedFields` as `reproof_required` or `reproof_failed` (or, when the address was the only field sent, 401 `auth.reproof.required` / `auth.reproof.failed`). Attempts draw on the account's shared re-proof budget of five per fifteen minutes. Never stored.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "ProfileUpdateRequest",
|
||||
|
||||
@@ -78,6 +78,10 @@ const REJECTION_REASON_KEYS: Record<string, string> = {
|
||||
// still lands. Without an entry it would fall back to "check it and try
|
||||
// again", which is wrong — the value was fine, the timing was not.
|
||||
rate_limited: "settings.profileRejection.rateLimited",
|
||||
// Not Zod codes either: a new email address asks for a fresh proof, and
|
||||
// these say whether none was given or the one given was wrong.
|
||||
reproof_required: "settings.profileRejection.reproofRequired",
|
||||
reproof_failed: "settings.profileRejection.reproofFailed",
|
||||
too_big: "settings.profileRejection.tooBig",
|
||||
too_small: "settings.profileRejection.tooSmall",
|
||||
invalid_value: "settings.profileRejection.invalidValue",
|
||||
|
||||
@@ -55,6 +55,8 @@ export const stepUpMintSchema = z
|
||||
"Re-prove a factor to mint a single-use, token-bound step-up elevation. `password` reaches the same routes a plain cookie session reaches; `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes (disable, recovery-code rotation, security-key removal), matching exactly the ceremonies for which the web stamps a session second-factor-verified.",
|
||||
});
|
||||
|
||||
export type StepUpMintRequest = z.infer<typeof stepUpMintSchema>;
|
||||
|
||||
/** Which assertion ceremony to begin at `POST /api/auth/step-up/options`. */
|
||||
export const stepUpOptionsSchema = z
|
||||
.object({
|
||||
|
||||
+27
-18
@@ -5,6 +5,7 @@ import {
|
||||
retiredRouteEnvelope,
|
||||
RETIRED_ROUTE_STATUS,
|
||||
} from "@/lib/http/retired-routes";
|
||||
import { setBaselineSecurityHeaders } from "@/lib/http/proxy-bypass-routes";
|
||||
|
||||
/**
|
||||
* Paths that do NOT require a session cookie (public pages + external webhooks).
|
||||
@@ -289,21 +290,7 @@ function generateNonce(): string {
|
||||
* routes that only make sense for a response with a body to protect.
|
||||
*/
|
||||
function applyBaselineSecurityHeaders(response: NextResponse): NextResponse {
|
||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
response.headers.set("X-Frame-Options", "DENY");
|
||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
response.headers.set(
|
||||
"Permissions-Policy",
|
||||
"camera=(), microphone=(), geolocation=()",
|
||||
);
|
||||
response.headers.set("Cross-Origin-Opener-Policy", "same-origin");
|
||||
response.headers.set("X-Permitted-Cross-Domain-Policies", "none");
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
response.headers.set(
|
||||
"Strict-Transport-Security",
|
||||
"max-age=31536000; includeSubDomains; preload",
|
||||
);
|
||||
}
|
||||
setBaselineSecurityHeaders(response.headers);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -647,6 +634,21 @@ export function proxy(request: NextRequest) {
|
||||
pathname.startsWith("/settings/integrations/strava") ||
|
||||
pathname.startsWith("/api/strava/");
|
||||
const stravaConnectSrc = isStravaRoute ? " https://www.strava.com" : "";
|
||||
// Local lab OCR runs tesseract's WebAssembly engine in a same-origin worker
|
||||
// served from `/tesseract/`. A worker runs under the CSP delivered with its
|
||||
// own script, so compiling the WebAssembly needs `'wasm-unsafe-eval'` on
|
||||
// that response. It is also granted on the two pages that start the OCR
|
||||
// (`/labs` for lab scans, `/documents` for document text), in case the
|
||||
// engine compiles on the page. Nowhere else: `'wasm-unsafe-eval'` allows
|
||||
// compiling WebAssembly, not `eval`, but a page that never runs the engine
|
||||
// has no reason to carry it. (A client-side navigation keeps the CSP of
|
||||
// the page first loaded, which is why the worker script's own response is
|
||||
// the grant that matters.)
|
||||
const isLocalOcrSurface =
|
||||
pathname.startsWith("/tesseract/") ||
|
||||
pathname === "/labs" ||
|
||||
pathname === "/documents";
|
||||
const wasmScriptSrc = isLocalOcrSurface ? " 'wasm-unsafe-eval'" : "";
|
||||
// v1.5.5 — Gravatar host removed from `img-src`. The /me payload
|
||||
// used to return `gravatarUrl: https://www.gravatar.com/avatar/<sha256(email)>`,
|
||||
// which leaked the email digest to Automattic on every authenticated
|
||||
@@ -682,7 +684,7 @@ export function proxy(request: NextRequest) {
|
||||
// is barred by the record-fence guard.
|
||||
isDev
|
||||
? `default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; frame-src 'self' blob:; connect-src 'self'; font-src 'self';`
|
||||
: `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; frame-src 'self' blob:; connect-src 'self'${aiConnectSrc}${withingsConnectSrc}${whoopConnectSrc}${stravaConnectSrc}; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; worker-src 'self'; report-uri ${cspReportEndpoint}; report-to csp-endpoint;`;
|
||||
: `default-src 'self'; script-src 'self' 'nonce-${nonce}'${wasmScriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; frame-src 'self' blob:; connect-src 'self'${aiConnectSrc}${withingsConnectSrc}${whoopConnectSrc}${stravaConnectSrc}; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; worker-src 'self'; report-uri ${cspReportEndpoint}; report-to csp-endpoint;`;
|
||||
// The MCP OAuth authorization endpoint sets its own CSP. Its consent form
|
||||
// posts to this origin and the server then redirects to the connecting
|
||||
// client's `redirect_uri`; Chromium applies `form-action` to that redirect,
|
||||
@@ -715,7 +717,14 @@ export function proxy(request: NextRequest) {
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
// Apply to all routes except static files, Next.js internals, SW, and manifest
|
||||
"/((?!_next/static|_next/image|favicon.ico|sw\\.js|manifest\\.json|robots\\.txt|sitemap\\.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|txt|xml)$).*)",
|
||||
// Apply to all routes except static files, Next.js internals, SW, and
|
||||
// manifest — and except the large-upload API routes listed in
|
||||
// `src/lib/http/proxy-bypass-routes.ts`. Next reads a request body into
|
||||
// memory for the proxy before the route runs, so a route whose body may
|
||||
// exceed `proxyClientMaxBodySize` (1 MB, next.config.ts) is left out of
|
||||
// the matcher and `apiHandler` performs the proxy's duties for it. The
|
||||
// alternation must stay equal to that list; `proxy-body-limit.test.ts`
|
||||
// compiles this exactly as Next does and checks.
|
||||
"/((?!_next/static|_next/image|favicon.ico|sw\\.js|manifest\\.json|robots\\.txt|sitemap\\.xml|api/(?:import(?:/csv|/apple-health-export)?|admin/import-apple-health-export|admin/backups/upload|documents/inbound|medications/intake/(?:dose-history-import|bulk)|labs/ocr/extract|user/avatar|measurements/batch|workouts/batch|insights/ecg|mood-entries/bulk|cycle/day-logs/bulk)/?$|.*\\.(?:svg|png|jpg|jpeg|gif|webp|txt|xml)$).*)",
|
||||
],
|
||||
};
|
||||
@@ -214,6 +214,30 @@ describe("POST /api/auth/password (real Postgres)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("audits a wrong current password and shares the re-proof ceiling", async () => {
|
||||
const { userId } = await seedUserWithSession();
|
||||
const prisma = getPrismaClient();
|
||||
const { POST } = await import("@/app/api/auth/password/route");
|
||||
|
||||
const statuses: number[] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
currentPassword: `WrongPasswordValue!${i}`,
|
||||
newPassword: STRONG_NEW,
|
||||
confirmPassword: STRONG_NEW,
|
||||
}),
|
||||
);
|
||||
statuses.push(res.status);
|
||||
}
|
||||
expect(statuses).toEqual([401, 401, 401, 401, 401, 429]);
|
||||
expect(
|
||||
await prisma.auditLog.count({
|
||||
where: { userId, action: "auth.mfa.failed" },
|
||||
}),
|
||||
).toBe(5);
|
||||
});
|
||||
|
||||
it("returns 422 when the new password matches the current password", async () => {
|
||||
await seedUserWithSession();
|
||||
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Adding a factor to an account needs a fresh proof, not just a live session.
|
||||
*
|
||||
* The chain this closes: a stolen browser session starts TOTP enrollment, reads
|
||||
* a code from the secret it was just handed, and presents that code as "an
|
||||
* existing second factor" — to mint a step-up, to register its own passkey, to
|
||||
* pass the fresh-factor gates. Two rules break it, and both are pinned here
|
||||
* against real Postgres:
|
||||
*
|
||||
* 1. a code from a secret that was never confirmed is not a factor anywhere;
|
||||
* 2. the cookie arm of TOTP and security-key enrollment wants a sign-in or a
|
||||
* second factor from the last five minutes, or a proof in the body.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import * as OTPAuth from "otpauth";
|
||||
import { 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-enrollment-proof-32-bytes-minimum-1234567890";
|
||||
|
||||
const { hashToken } = await import("@/lib/auth/hmac");
|
||||
const { hashPassword } = await import("@/lib/auth/password");
|
||||
|
||||
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 USER_ID = "user-enroll-proof";
|
||||
const PASSWORD = "Correct horse battery staple!42";
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
type RouteFn = (request: NextRequest) => Promise<Response>;
|
||||
|
||||
function post(path: string, body?: unknown): NextRequest {
|
||||
const headers: Record<string, string> = {};
|
||||
for (const name of ["authorization", "x-step-up"]) {
|
||||
const value = headerJar.get(name);
|
||||
if (value) headers[name] = value;
|
||||
}
|
||||
if (body !== undefined) headers["content-type"] = "application/json";
|
||||
return new NextRequest(`http://localhost${path}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
}
|
||||
|
||||
async function totpSetup(body?: unknown) {
|
||||
const { POST } = await import("@/app/api/auth/me/mfa/totp/setup/route");
|
||||
return (POST as unknown as RouteFn)(
|
||||
post("/api/auth/me/mfa/totp/setup", body),
|
||||
);
|
||||
}
|
||||
|
||||
async function securityKeyOptions(body?: unknown) {
|
||||
const { POST } =
|
||||
await import("@/app/api/auth/me/mfa/webauthn/register/options/route");
|
||||
return (POST as unknown as RouteFn)(
|
||||
post("/api/auth/me/mfa/webauthn/register/options", body),
|
||||
);
|
||||
}
|
||||
|
||||
async function stepUpMint(body: unknown) {
|
||||
const { POST } = await import("@/app/api/auth/step-up/route");
|
||||
return (POST as unknown as RouteFn)(post("/api/auth/step-up", body));
|
||||
}
|
||||
|
||||
async function startSession(opts: {
|
||||
createdAgoMs: number;
|
||||
mfaVerifiedAgoMs?: number;
|
||||
}): Promise<string> {
|
||||
const now = Date.now();
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
createdAt: new Date(now - opts.createdAgoMs),
|
||||
expiresAt: new Date(now + HOUR),
|
||||
mfaVerifiedAt:
|
||||
opts.mfaVerifiedAgoMs === undefined
|
||||
? null
|
||||
: new Date(now - opts.mfaVerifiedAgoMs),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return session.id;
|
||||
}
|
||||
|
||||
async function seedPendingTotp(): Promise<string> {
|
||||
const { generateTotpSecret } = await import("@/lib/auth/mfa/totp");
|
||||
const { encrypt } = await import("@/lib/crypto");
|
||||
const secret = generateTotpSecret();
|
||||
await getPrismaClient().user.update({
|
||||
where: { id: USER_ID },
|
||||
data: {
|
||||
totpSecretEncrypted: encrypt(secret),
|
||||
totpConfirmedAt: null,
|
||||
totpLastStep: null,
|
||||
},
|
||||
});
|
||||
return secret;
|
||||
}
|
||||
|
||||
function codeFor(secret: string): string {
|
||||
return new OTPAuth.TOTP({
|
||||
issuer: "HealthLog",
|
||||
label: "HealthLog",
|
||||
algorithm: "SHA1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: OTPAuth.Secret.fromBase32(secret),
|
||||
}).generate({ timestamp: Date.now() });
|
||||
}
|
||||
|
||||
async function errorCode(res: Response): Promise<string | undefined> {
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
return body.meta?.errorCode;
|
||||
}
|
||||
|
||||
async function storedSecret(): Promise<string | null> {
|
||||
const user = await getPrismaClient().user.findUniqueOrThrow({
|
||||
where: { id: USER_ID },
|
||||
select: { totpSecretEncrypted: true },
|
||||
});
|
||||
return user.totpSecretEncrypted;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
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: "enroll-user",
|
||||
email: "enroll@example.test",
|
||||
role: "USER",
|
||||
passwordHash: await hashPassword(PASSWORD),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("TOTP enrollment on the cookie arm", () => {
|
||||
it("refuses a session that is neither fresh nor carrying a proof", async () => {
|
||||
await startSession({ createdAgoMs: HOUR });
|
||||
const res = await totpSetup();
|
||||
expect(res.status).toBe(401);
|
||||
expect(await errorCode(res)).toBe("auth.reproof.required");
|
||||
expect(await storedSecret()).toBeNull();
|
||||
});
|
||||
|
||||
it("enrolls with the account password in the body", async () => {
|
||||
await startSession({ createdAgoMs: HOUR });
|
||||
const res = await totpSetup({ method: "password", password: PASSWORD });
|
||||
expect(res.status).toBe(200);
|
||||
expect(await storedSecret()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("enrolls without a body right after signing in", async () => {
|
||||
await startSession({ createdAgoMs: 30_000 });
|
||||
const res = await totpSetup();
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("enrolls without a body after a recent second factor", async () => {
|
||||
await startSession({ createdAgoMs: HOUR, mfaVerifiedAgoMs: 60_000 });
|
||||
const res = await totpSetup();
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("refuses and audits a wrong password", async () => {
|
||||
await startSession({ createdAgoMs: HOUR });
|
||||
const res = await totpSetup({ method: "password", password: "nope" });
|
||||
expect(res.status).toBe(401);
|
||||
expect(await errorCode(res)).toBe("auth.reproof.failed");
|
||||
expect(await storedSecret()).toBeNull();
|
||||
expect(
|
||||
await getPrismaClient().auditLog.count({
|
||||
where: { userId: USER_ID, action: "auth.mfa.failed" },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("does not take a code from its own pending secret as the proof", async () => {
|
||||
const secret = await seedPendingTotp();
|
||||
await startSession({ createdAgoMs: HOUR });
|
||||
const res = await totpSetup({ method: "totp", code: codeFor(secret) });
|
||||
expect(res.status).toBe(401);
|
||||
expect(await errorCode(res)).toBe("auth.reproof.failed");
|
||||
});
|
||||
|
||||
it("cannot be guessed at past the shared re-proof ceiling", async () => {
|
||||
await startSession({ createdAgoMs: HOUR });
|
||||
const statuses: number[] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
statuses.push(
|
||||
(await totpSetup({ method: "password", password: `guess-${i}` }))
|
||||
.status,
|
||||
);
|
||||
}
|
||||
expect(statuses).toEqual([401, 401, 401, 401, 401, 429]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("security-key enrollment on the cookie arm", () => {
|
||||
it("refuses a stale session without a proof and creates no challenge", async () => {
|
||||
await startSession({ createdAgoMs: HOUR });
|
||||
const res = await securityKeyOptions();
|
||||
expect(res.status).toBe(401);
|
||||
expect(await errorCode(res)).toBe("auth.reproof.required");
|
||||
expect(await getPrismaClient().authChallenge.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("issues options once the password is re-proved", async () => {
|
||||
await startSession({ createdAgoMs: HOUR });
|
||||
const res = await securityKeyOptions({
|
||||
method: "password",
|
||||
password: PASSWORD,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await getPrismaClient().authChallenge.count()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a pending TOTP secret on the Bearer arm", () => {
|
||||
it("cannot mint a step-up elevation", async () => {
|
||||
const secret = await seedPendingTotp();
|
||||
const raw = `hlk_enrollproof${"0".repeat(64 - "enrollproof".length)}`;
|
||||
await getPrismaClient().apiToken.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
name: "phone",
|
||||
tokenHash: hashToken(raw),
|
||||
permissions: ["*"],
|
||||
},
|
||||
});
|
||||
headerJar.set("authorization", `Bearer ${raw}`);
|
||||
|
||||
const res = await stepUpMint({ method: "totp", code: codeFor(secret) });
|
||||
expect(res.status).toBe(401);
|
||||
expect(await getPrismaClient().stepUpElevation.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -63,7 +63,12 @@ describe("MFA login flow (real Postgres)", () => {
|
||||
const code = codeAt(secret, Date.now());
|
||||
|
||||
const first = await verifyMfaFactor(
|
||||
{ id: user.id, totpSecretEncrypted: encrypt(secret), totpLastStep: null },
|
||||
{
|
||||
id: user.id,
|
||||
totpSecretEncrypted: encrypt(secret),
|
||||
totpLastStep: null,
|
||||
totpConfirmedAt: user.totpConfirmedAt,
|
||||
},
|
||||
"totp",
|
||||
code,
|
||||
);
|
||||
@@ -81,6 +86,7 @@ describe("MFA login flow (real Postgres)", () => {
|
||||
id: user.id,
|
||||
totpSecretEncrypted: encrypt(secret),
|
||||
totpLastStep: afterFirst?.totpLastStep ?? null,
|
||||
totpConfirmedAt: user.totpConfirmedAt,
|
||||
},
|
||||
"totp",
|
||||
code,
|
||||
@@ -94,6 +100,7 @@ describe("MFA login flow (real Postgres)", () => {
|
||||
createMfaChallenge,
|
||||
loadActiveChallenge,
|
||||
claimChallenge,
|
||||
reserveChallengeAttempt,
|
||||
recordChallengeFailure,
|
||||
MFA_CHALLENGE_ATTEMPT_CAP,
|
||||
} = await import("@/lib/auth/mfa/challenge");
|
||||
@@ -117,10 +124,41 @@ describe("MFA login flow (real Postgres)", () => {
|
||||
const row = await loadActiveChallenge(second.ticket);
|
||||
let exhausted = false;
|
||||
for (let i = 0; i < MFA_CHALLENGE_ATTEMPT_CAP; i++) {
|
||||
expect(await reserveChallengeAttempt(row!.id)).toBe(true);
|
||||
({ exhausted } = await recordChallengeFailure(row!.id));
|
||||
}
|
||||
expect(exhausted).toBe(true);
|
||||
expect(await loadActiveChallenge(second.ticket)).toBeNull();
|
||||
expect(await reserveChallengeAttempt(row!.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("concurrent guesses cannot exceed the attempt cap", async () => {
|
||||
const {
|
||||
createMfaChallenge,
|
||||
loadActiveChallenge,
|
||||
reserveChallengeAttempt,
|
||||
MFA_CHALLENGE_ATTEMPT_CAP,
|
||||
} = await import("@/lib/auth/mfa/challenge");
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: { username: "race-user", email: "race@example.test" },
|
||||
});
|
||||
const { ticket } = await createMfaChallenge(user.id, "login");
|
||||
const active = await loadActiveChallenge(ticket);
|
||||
|
||||
// Every request read "0 attempts" under the old read-then-increment; fire
|
||||
// four times the cap at once and count how many were allowed to guess.
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: MFA_CHALLENGE_ATTEMPT_CAP * 4 }, () =>
|
||||
reserveChallengeAttempt(active!.id),
|
||||
),
|
||||
);
|
||||
expect(results.filter(Boolean)).toHaveLength(MFA_CHALLENGE_ATTEMPT_CAP);
|
||||
const row = await prisma.mfaChallenge.findUnique({
|
||||
where: { id: active!.id },
|
||||
select: { attempts: true },
|
||||
});
|
||||
expect(row?.attempts).toBe(MFA_CHALLENGE_ATTEMPT_CAP);
|
||||
});
|
||||
|
||||
it("recovery code is one-time-use and regeneration invalidates the set", async () => {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* A single sign-on identity is linked to an existing account only after the
|
||||
* account's own credential is proved in the same browser.
|
||||
*
|
||||
* The callback leaves a sealed pending link (unit-tested in the callback's own
|
||||
* suite); this pins the other half against real Postgres: which sign-ins
|
||||
* complete it, which do not, and that an SSO-only policy opens the password
|
||||
* form for exactly the account the IdP vouched for and nothing else.
|
||||
*/
|
||||
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-pending-link-32-bytes-minimum-1234567890";
|
||||
|
||||
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 { hashPassword } = await import("@/lib/auth/password");
|
||||
const { encrypt } = await import("@/lib/crypto");
|
||||
|
||||
const PASSWORD = "Correct horse battery staple!42";
|
||||
const ISSUER = "https://idp.example.test";
|
||||
const OIDC_ENV = {
|
||||
OIDC_ISSUER_URL: ISSUER,
|
||||
OIDC_CLIENT_ID: "client",
|
||||
OIDC_CLIENT_SECRET: "secret",
|
||||
};
|
||||
|
||||
function pendingLinkFor(userId: string, expiresInMs = 60_000): void {
|
||||
cookieJar.set(
|
||||
"hl_oidc_link",
|
||||
encrypt(
|
||||
JSON.stringify({
|
||||
u: userId,
|
||||
i: ISSUER,
|
||||
s: "idp-sub-1",
|
||||
e: Date.now() + expiresInMs,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function passwordLogin(identifier: string, password = PASSWORD) {
|
||||
const { POST } = await import("@/app/api/auth/login/route");
|
||||
return POST(
|
||||
new NextRequest("http://localhost/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email: identifier, password }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function linkOf(id: string) {
|
||||
return getPrismaClient().user.findUniqueOrThrow({
|
||||
where: { id },
|
||||
select: { oidcIssuer: true, oidcSub: true },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
process.env.APP_URL = "http://localhost:3000";
|
||||
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
|
||||
const hash = await hashPassword(PASSWORD);
|
||||
await getPrismaClient().user.createMany({
|
||||
data: [
|
||||
{
|
||||
id: "acct-a",
|
||||
username: "acct-a",
|
||||
email: "a@example.test",
|
||||
passwordHash: hash,
|
||||
onboardingCompletedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "acct-b",
|
||||
username: "acct-b",
|
||||
email: "b@example.test",
|
||||
passwordHash: hash,
|
||||
onboardingCompletedAt: new Date(),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of [...Object.keys(OIDC_ENV), "OIDC_ONLY"]) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
describe("pending single sign-on link", () => {
|
||||
it("is completed by a password sign-in to the named account", async () => {
|
||||
pendingLinkFor("acct-a");
|
||||
const res = await passwordLogin("acct-a");
|
||||
expect(res.status).toBe(200);
|
||||
expect(await linkOf("acct-a")).toEqual({
|
||||
oidcIssuer: ISSUER,
|
||||
oidcSub: "idp-sub-1",
|
||||
});
|
||||
expect(
|
||||
await getPrismaClient().auditLog.count({
|
||||
where: { userId: "acct-a", action: "auth.oidc.linked" },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("is not completed by a sign-in to a different account", async () => {
|
||||
pendingLinkFor("acct-a");
|
||||
const res = await passwordLogin("acct-b");
|
||||
expect(res.status).toBe(200);
|
||||
expect(await linkOf("acct-a")).toEqual({ oidcIssuer: null, oidcSub: null });
|
||||
expect(await linkOf("acct-b")).toEqual({ oidcIssuer: null, oidcSub: null });
|
||||
});
|
||||
|
||||
it("is not completed by a wrong password", async () => {
|
||||
pendingLinkFor("acct-a");
|
||||
const res = await passwordLogin("acct-a", "not the password");
|
||||
expect(res.status).toBe(401);
|
||||
expect(await linkOf("acct-a")).toEqual({ oidcIssuer: null, oidcSub: null });
|
||||
});
|
||||
|
||||
it("expires", async () => {
|
||||
pendingLinkFor("acct-a", -1);
|
||||
await passwordLogin("acct-a");
|
||||
expect(await linkOf("acct-a")).toEqual({ oidcIssuer: null, oidcSub: null });
|
||||
});
|
||||
|
||||
it("refuses a forged cookie", async () => {
|
||||
cookieJar.set(
|
||||
"hl_oidc_link",
|
||||
JSON.stringify({ u: "acct-a", i: ISSUER, s: "x", e: Date.now() + 1e6 }),
|
||||
);
|
||||
await passwordLogin("acct-a");
|
||||
expect(await linkOf("acct-a")).toEqual({ oidcIssuer: null, oidcSub: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("under an SSO-only policy", () => {
|
||||
beforeEach(() => {
|
||||
Object.assign(process.env, OIDC_ENV, { OIDC_ONLY: "true" });
|
||||
});
|
||||
|
||||
it("keeps password sign-in closed without a pending link", async () => {
|
||||
const res = await passwordLogin("acct-a");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("opens it for the account the pending link names, and links it", async () => {
|
||||
pendingLinkFor("acct-a");
|
||||
const res = await passwordLogin("acct-a");
|
||||
expect(res.status).toBe(200);
|
||||
expect((await linkOf("acct-a")).oidcSub).toBe("idp-sub-1");
|
||||
});
|
||||
|
||||
it("keeps it closed for any other account", async () => {
|
||||
pendingLinkFor("acct-a");
|
||||
const res = await passwordLogin("acct-b");
|
||||
expect(res.status).toBe(403);
|
||||
expect(
|
||||
await getPrismaClient().session.count({ where: { userId: "acct-b" } }),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -359,6 +359,61 @@ describe("passkey register-options existing-factor proof (real Postgres)", () =>
|
||||
await expectNoCredential();
|
||||
});
|
||||
|
||||
it("refuses a code from a TOTP secret that was never confirmed", async () => {
|
||||
const secret = await seedTotp();
|
||||
await getPrismaClient().user.update({
|
||||
where: { id: USER_ID },
|
||||
data: { totpConfirmedAt: null },
|
||||
});
|
||||
|
||||
const response = await registerOptions({
|
||||
method: "totp",
|
||||
code: currentTotpCode(secret),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(await getPrismaClient().authChallenge.count()).toBe(0);
|
||||
const session = await getPrismaClient().session.findFirstOrThrow({
|
||||
where: { userId: USER_ID },
|
||||
select: { mfaVerifiedAt: true },
|
||||
});
|
||||
expect(session.mfaVerifiedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("audits every wrong proof and stops guessing after five", async () => {
|
||||
const statuses: number[] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const response = await registerOptions({
|
||||
method: "password",
|
||||
password: `wrong-guess-${i}`,
|
||||
});
|
||||
statuses.push(response.status);
|
||||
}
|
||||
|
||||
expect(statuses).toEqual([401, 401, 401, 401, 401, 429]);
|
||||
const failed = await getPrismaClient().auditLog.findMany({
|
||||
where: { userId: USER_ID, action: "auth.mfa.failed" },
|
||||
select: { details: true },
|
||||
});
|
||||
expect(failed).toHaveLength(5);
|
||||
const details = failed[0].details;
|
||||
expect(
|
||||
typeof details === "string" ? JSON.parse(details) : details,
|
||||
).toMatchObject({
|
||||
stage: "passkey_enroll",
|
||||
method: "password",
|
||||
reason: "bad_password",
|
||||
});
|
||||
|
||||
// The bucket is spent, so even the right password waits it out.
|
||||
const correct = await registerOptions({
|
||||
method: "password",
|
||||
password: PASSWORD,
|
||||
});
|
||||
expect(correct.status).toBe(429);
|
||||
expect(await getPrismaClient().authChallenge.count()).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["wildcard", ["*"]],
|
||||
["narrow", ["medication:ingest"]],
|
||||
|
||||
Reference in new issue
Block a user