merge: integrate the v1.23 security settings and MFA wave

# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/pl.json
#	src/lib/openapi/routes/auth.ts
#	src/lib/query-keys/auth.ts
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:46:03 +02:00
commit c458d955e4
45 files changed
+4211 -551

No files matched your search

+444 -1
View File
@@ -1502,6 +1502,145 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/api/auth/me/mfa:
get:
tags:
- Auth
summary: Second-factor status (cookie session only)
description: Whether TOTP is active, how many recovery codes remain, and the registered WebAuthn security keys. Metadata
only — no secret, code, or public key. Cookie-only.
responses:
"200":
description: Second-factor status.
content:
application/json:
schema:
$ref: "#/components/schemas/MfaStatusEnvelope"
"401": *a1
"422": *a3
"429": *a4
/api/auth/me/mfa/webauthn/register/options:
post:
tags:
- Auth
summary: Begin registering a security key as a second factor
description: Returns SimpleWebAuthn creation options + a challenge id. Cookie-only — a Bearer token cannot enrol MFA.
responses:
"200":
description: Registration options issued.
content:
application/json:
schema:
$ref: "#/components/schemas/MfaWebauthnRegisterOptionsEnvelope"
"401": *a1
"422": *a3
"429": *a4
/api/auth/me/mfa/webauthn/register/verify:
post:
tags:
- Auth
summary: Finish registering a security key as a second factor
description: Verifies the attestation against the user-bound challenge and stores the credential in the second-factor
store (separate from primary passkeys). Cookie-only.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MfaWebauthnRegisterVerifyRequest"
responses:
"200":
description: Security key registered.
content:
application/json:
schema:
$ref: "#/components/schemas/MfaWebauthnRegisterVerifyEnvelope"
"401": *a1
"422": *a3
"429": *a4
/api/auth/me/mfa/webauthn/{id}:
patch:
tags:
- Auth
summary: Rename a registered security key
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MfaWebauthnRenameRequest"
responses:
"200":
description: Security key renamed.
content:
application/json:
schema:
$ref: "#/components/schemas/MfaWebauthnRenameEnvelope"
"401": *a1
"422": *a3
"429": *a4
delete:
tags:
- Auth
summary: Remove a registered security key (step-up gated)
description: Requires a fresh second-factor step-up (cookie session). Bearer can never satisfy the gate.
responses:
"200":
description: Security key removed.
content:
application/json:
schema:
$ref: "#/components/schemas/MfaWebauthnRemoveEnvelope"
"401": *a1
"422": *a3
"429": *a4
/api/auth/mfa/webauthn/verify/options:
post:
tags:
- Auth
summary: Begin a mid-login security-key assertion
description: Presents the login `mfaTicket` and returns assertion options scoped to the password-identified user's
registered security keys. Anonymous surface; rate-limited.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MfaWebauthnLoginOptionsRequest"
responses:
"200":
description: Assertion options issued.
content:
application/json:
schema:
$ref: "#/components/schemas/MfaWebauthnLoginOptionsEnvelope"
"401": *a1
"422": *a3
"429": *a4
/api/auth/mfa/webauthn/verify:
post:
tags:
- Auth
summary: Complete a security-key second-factor login challenge
description: Presents the login `mfaTicket` plus the assertion. On success returns the SAME token bundle / session the
password path issues, with the session marked second-factor-verified. The ticket is single-use; failures are
throttled and the ticket is burned at the attempt cap.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MfaWebauthnLoginVerifyRequest"
responses:
"200":
description: Second factor verified — session + optional bearer issued.
content:
application/json:
schema:
$ref: "#/components/schemas/MfaWebauthnVerifyResponse"
"401": *a1
"422": *a3
"429": *a4
/api/auth/passkey/login-verify:
post:
tags:
@@ -7724,6 +7863,115 @@ components:
required:
- code
description: Disable the second factor. Requires fresh step-up plus a current TOTP or recovery code.
MfaWebauthnRegisterVerifyRequest:
type: object
properties:
challengeId:
type: string
minLength: 1
credential:
type: object
properties:
id:
type: string
rawId:
type: string
type:
type: string
const: public-key
response:
type: object
propertyNames:
type: string
additionalProperties: {}
authenticatorAttachment:
type: string
enum:
- platform
- cross-platform
clientExtensionResults:
type: object
propertyNames:
type: string
additionalProperties: {}
required:
- id
- rawId
- type
- response
description: SimpleWebAuthn ceremony response payload.
name:
type: string
minLength: 1
maxLength: 64
required:
- challengeId
- credential
description: Finish registering a WebAuthn security key as a second factor.
MfaWebauthnRenameRequest:
type: object
properties:
name:
type: string
minLength: 1
maxLength: 64
required:
- name
MfaWebauthnLoginOptionsRequest:
type: object
properties:
mfaTicket:
type: string
minLength: 1
description: The opaque single-use ticket from the login response.
required:
- mfaTicket
MfaWebauthnLoginVerifyRequest:
type: object
properties:
mfaTicket:
type: string
minLength: 1
challengeId:
type: string
minLength: 1
credential:
type: object
properties:
id:
type: string
rawId:
type: string
type:
type: string
const: public-key
response:
type: object
propertyNames:
type: string
additionalProperties: {}
authenticatorAttachment:
type: string
enum:
- platform
- cross-platform
clientExtensionResults:
type: object
propertyNames:
type: string
additionalProperties: {}
required:
- id
- rawId
- type
- response
description: SimpleWebAuthn ceremony response payload.
required:
- mfaTicket
- challengeId
- credential
description: Complete a second-factor login challenge with a WebAuthn security key and receive the same token bundle the
password path issues.
PasskeyLoginVerifyRequest:
type: object
properties:
@@ -10915,7 +11163,9 @@ components:
enum:
- totp
- recovery
description: Second factors the account can complete the challenge with.
- webauthn
description: Second factors the account can complete the challenge with. `webauthn` is completed via
/api/auth/mfa/webauthn/verify; the rest via /api/auth/mfa/verify.
required:
- mfaRequired
- mfaTicket
@@ -11052,6 +11302,199 @@ components:
- data
- error
additionalProperties: false
MfaStatusEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/MfaStatusResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MfaStatusResponse:
type: object
properties:
totp:
type: object
properties:
enabled:
type: boolean
required:
- enabled
additionalProperties: false
recoveryCodesRemaining:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
webauthn:
type: array
items:
$ref: "#/components/schemas/WebauthnMfaCredentialInfo"
passkeyNudgeDismissed:
type: boolean
required:
- totp
- recoveryCodesRemaining
- webauthn
- passkeyNudgeDismissed
additionalProperties: false
WebauthnMfaCredentialInfo:
type: object
properties:
id:
type: string
name:
type: string
createdAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
lastUsedAt:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
required:
- id
- name
- createdAt
- lastUsedAt
additionalProperties: false
MfaWebauthnRegisterOptionsEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/WebauthnOptionsResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
WebauthnOptionsResponse:
type: object
properties:
options:
type: object
propertyNames:
type: string
additionalProperties: {}
description: SimpleWebAuthn options to pass to the browser ceremony.
challengeId:
type: string
description: Server-issued challenge id.
required:
- options
- challengeId
additionalProperties: false
MfaWebauthnRegisterVerifyEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/WebauthnMfaCredentialInfo"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MfaWebauthnRenameEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/WebauthnMfaCredentialInfo"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MfaWebauthnRemoveEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/AuthSuccessFlagResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
AuthSuccessFlagResponse:
type: object
properties:
success:
type: boolean
required:
- success
additionalProperties: false
MfaWebauthnLoginOptionsEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/WebauthnOptionsResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MfaWebauthnVerifyResponse:
type: object
properties:
data:
$ref: "#/components/schemas/AccessRefreshBundle"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
PasskeyLoginVerifyResponse:
type: object
properties:
+75 -2
View File
@@ -35,7 +35,8 @@
"reportIssue": "Fehler melden",
"showPassword": "Passwort anzeigen",
"hidePassword": "Passwort verbergen",
"learnMore": "Mehr erfahren"
"learnMore": "Mehr erfahren",
"dismiss": "Schließen"
},
"doctorReport": {
"title": "Gesundheitsbericht",
@@ -264,7 +265,21 @@
"passwordMinLength": "Min. 12 Zeichen",
"passwordTooShort": "Passwort muss mindestens {minLength} Zeichen lang sein.",
"privacyPolicy": "Datenschutzerklärung",
"passwordBreached": "Dieses Passwort taucht in bekannten Datenlecks auf. Bitte wähle ein anderes."
"passwordBreached": "Dieses Passwort taucht in bekannten Datenlecks auf. Bitte wähle ein anderes.",
"mfa": {
"title": "Zwei-Faktor-Authentifizierung",
"totpHint": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
"recoveryHint": "Gib einen deiner gespeicherten Wiederherstellungscodes ein.",
"securityKeyHint": "Verwende deinen registrierten Sicherheitsschlüssel, um fortzufahren.",
"codeLabel": "Authentifizierungscode",
"recoveryLabel": "Wiederherstellungscode",
"verify": "Bestätigen",
"verifyFailed": "Bestätigung fehlgeschlagen. Bitte versuche es erneut.",
"useSecurityKey": "Sicherheitsschlüssel verwenden",
"useRecoveryCode": "Wiederherstellungscode verwenden",
"useAuthenticator": "Authenticator-App verwenden",
"backToLogin": "Zurück zur Anmeldung"
}
},
"dashboard": {
"title": "Dashboard",
@@ -4198,6 +4213,10 @@
"title": "Kanäle",
"description": "Zustellung über Telegram, ntfy, Web-Push, Webhook und E-Mail.",
"subtitle": "Wohin Erinnerungen und Hinweise zugestellt werden."
},
"security": {
"title": "Sicherheit",
"subtitle": "Zwei-Faktor-Authentifizierung, Wiederherstellungscodes und Passkeys."
}
},
"profile": "Profil",
@@ -4998,6 +5017,60 @@
"dataClear": "Daten zurückgesetzt",
"export": "Datenexport",
"other": "Sicherheitsereignis"
},
"active": "Aktiv",
"inactive": "Inaktiv",
"stepUpRequired": "Melde dich aus Sicherheitsgründen erneut an, um deinen zweiten Faktor zu ändern.",
"useRecoveryCode": "Stattdessen Wiederherstellungscode verwenden",
"useAuthenticator": "Stattdessen Authenticator-App verwenden",
"totp": {
"title": "Authenticator-App",
"description": "Verwende einen zeitbasierten Code aus einer Authenticator-App als zweiten Faktor.",
"setUp": "Einrichten",
"step1": "Scanne den QR-Code mit deiner Authenticator-App oder gib das Geheimnis manuell ein.",
"step2": "Gib zur Bestätigung den 6-stelligen Code aus der App ein.",
"qrAlt": "QR-Code zur Einrichtung der Authenticator-App",
"qrUnavailable": "QR-Code nicht verfügbar",
"manualEntry": "Oder gib dieses Geheimnis manuell ein",
"enterCode": "Gib den 6-stelligen Code ein",
"verify": "Bestätigen",
"setupFailed": "Einrichtung konnte nicht gestartet werden. Bitte versuche es erneut.",
"invalidCode": "Dieser Code ist ungültig. Bitte versuche es erneut.",
"disable": "Deaktivieren",
"disableTitle": "Zwei-Faktor-Authentifizierung deaktivieren",
"disableConfirm": "Gib einen aktuellen Code ein, um deinen zweiten Faktor abzuschalten. Damit werden auch deine Wiederherstellungscodes gelöscht.",
"currentCode": "Aktueller Code",
"disableFailed": "Der zweite Faktor konnte nicht deaktiviert werden."
},
"recovery": {
"saveNow": "Speichere diese Wiederherstellungscodes jetzt",
"saveNowHint": "Jeder Code funktioniert einmal. Sie werden nur dieses eine Mal angezeigt — bewahre sie sicher auf. Wenn du deine Authenticator-App verlierst, ist ein Wiederherstellungscode der einzige Weg zurück.",
"download": "Herunterladen",
"remainingOne": "{count} Wiederherstellungscode übrig.",
"remainingOther": "{count} Wiederherstellungscodes übrig.",
"regenerate": "Codes neu erzeugen",
"regenFailed": "Wiederherstellungscodes konnten nicht neu erzeugt werden.",
"savedIt": "Ich habe sie gespeichert"
},
"keys": {
"title": "Sicherheitsschlüssel",
"description": "Registriere einen Hardware- oder Plattform-Sicherheitsschlüssel als zweiten Faktor.",
"empty": "Noch keine Sicherheitsschlüssel registriert.",
"add": "Sicherheitsschlüssel hinzufügen",
"addFailed": "Der Sicherheitsschlüssel konnte nicht registriert werden.",
"rename": "Umbenennen",
"renameFailed": "Der Sicherheitsschlüssel konnte nicht umbenannt werden.",
"remove": "Sicherheitsschlüssel entfernen",
"removeFailed": "Der Sicherheitsschlüssel konnte nicht entfernt werden.",
"removeConfirm": "Dieser Sicherheitsschlüssel funktioniert dann nicht mehr als zweiter Faktor.",
"lastUsed": "Zuletzt verwendet {date}",
"neverUsed": "Noch nie verwendet",
"added": "hinzugefügt {date}",
"nameLabel": "Name des Sicherheitsschlüssels"
},
"passkeyNudge": {
"title": "Füge einen Passkey für schnellere Anmeldung hinzu",
"body": "Mit Passkeys meldest du dich per Fingerabdruck, Gesicht oder Geräte-PIN an — ohne Passwort zum Merken."
}
}
},
+75 -2
View File
@@ -35,7 +35,8 @@
"reportIssue": "Report issue",
"showPassword": "Show password",
"hidePassword": "Hide password",
"learnMore": "Learn more"
"learnMore": "Learn more",
"dismiss": "Dismiss"
},
"doctorReport": {
"title": "Health Report",
@@ -264,7 +265,21 @@
"passwordMinLength": "Min. 12 characters",
"passwordTooShort": "Password must be at least {minLength} characters long.",
"privacyPolicy": "Privacy policy",
"passwordBreached": "This password has appeared in known data breaches. Please choose a different one."
"passwordBreached": "This password has appeared in known data breaches. Please choose a different one.",
"mfa": {
"title": "Two-factor authentication",
"totpHint": "Enter the 6-digit code from your authenticator app.",
"recoveryHint": "Enter one of your saved recovery codes.",
"securityKeyHint": "Use your registered security key to continue.",
"codeLabel": "Authentication code",
"recoveryLabel": "Recovery code",
"verify": "Verify",
"verifyFailed": "Verification failed. Please try again.",
"useSecurityKey": "Use a security key",
"useRecoveryCode": "Use a recovery code",
"useAuthenticator": "Use your authenticator app",
"backToLogin": "Back to login"
}
},
"dashboard": {
"title": "Dashboard",
@@ -4198,6 +4213,10 @@
"title": "Channels",
"description": "Telegram, ntfy, Web Push, Webhook, and Email delivery.",
"subtitle": "Where reminders and alerts are delivered."
},
"security": {
"title": "Security",
"subtitle": "Two-factor authentication, recovery codes, and passkeys."
}
},
"profile": "Profile",
@@ -4998,6 +5017,60 @@
"dataClear": "Data reset",
"export": "Data export",
"other": "Security event"
},
"active": "Active",
"inactive": "Inactive",
"stepUpRequired": "For your security, sign in again to change your second factor.",
"useRecoveryCode": "Use a recovery code instead",
"useAuthenticator": "Use your authenticator app instead",
"totp": {
"title": "Authenticator app",
"description": "Use a time-based code from an authenticator app as your second factor.",
"setUp": "Set up",
"step1": "Scan the QR code with your authenticator app, or enter the secret manually.",
"step2": "Enter the 6-digit code the app shows to confirm.",
"qrAlt": "QR code for authenticator app setup",
"qrUnavailable": "QR code unavailable",
"manualEntry": "Or enter this secret manually",
"enterCode": "Enter the 6-digit code",
"verify": "Verify",
"setupFailed": "Could not start setup. Please try again.",
"invalidCode": "That code is not valid. Please try again.",
"disable": "Disable",
"disableTitle": "Disable two-factor authentication",
"disableConfirm": "Enter a current code to turn off your second factor. This also deletes your recovery codes.",
"currentCode": "Current code",
"disableFailed": "Could not disable the second factor."
},
"recovery": {
"saveNow": "Save these recovery codes now",
"saveNowHint": "Each code works once. They are shown only this time — store them somewhere safe. If you lose your authenticator, a recovery code is the only way back in.",
"download": "Download",
"remainingOne": "{count} recovery code remaining.",
"remainingOther": "{count} recovery codes remaining.",
"regenerate": "Regenerate codes",
"regenFailed": "Could not regenerate recovery codes.",
"savedIt": "I've saved them"
},
"keys": {
"title": "Security keys",
"description": "Register a hardware or platform security key as a second factor.",
"empty": "No security keys registered yet.",
"add": "Add security key",
"addFailed": "Could not register the security key.",
"rename": "Rename",
"renameFailed": "Could not rename the security key.",
"remove": "Remove security key",
"removeFailed": "Could not remove the security key.",
"removeConfirm": "This security key will no longer work as a second factor.",
"lastUsed": "Last used {date}",
"neverUsed": "Never used",
"added": "added {date}",
"nameLabel": "Security key name"
},
"passkeyNudge": {
"title": "Add a passkey for faster sign-in",
"body": "Passkeys let you sign in with your fingerprint, face, or device PIN — no password to remember."
}
}
},
+75 -2
View File
@@ -35,7 +35,8 @@
"reportIssue": "Reportar problema",
"showPassword": "Mostrar contraseña",
"hidePassword": "Ocultar contraseña",
"learnMore": "Más información"
"learnMore": "Más información",
"dismiss": "Descartar"
},
"doctorReport": {
"title": "Informe de salud",
@@ -264,7 +265,21 @@
"passwordMinLength": "Mín. 12 caracteres",
"passwordTooShort": "La contraseña debe tener al menos {minLength} caracteres.",
"privacyPolicy": "Política de privacidad",
"passwordBreached": "Esta contraseña aparece en filtraciones de datos conocidas. Elige otra."
"passwordBreached": "Esta contraseña aparece en filtraciones de datos conocidas. Elige otra.",
"mfa": {
"title": "Autenticación de dos factores",
"totpHint": "Introduce el código de 6 dígitos de tu aplicación de autenticación.",
"recoveryHint": "Introduce uno de tus códigos de recuperación guardados.",
"securityKeyHint": "Usa tu llave de seguridad registrada para continuar.",
"codeLabel": "Código de autenticación",
"recoveryLabel": "Código de recuperación",
"verify": "Verificar",
"verifyFailed": "La verificación falló. Inténtalo de nuevo.",
"useSecurityKey": "Usar una llave de seguridad",
"useRecoveryCode": "Usar un código de recuperación",
"useAuthenticator": "Usar tu aplicación de autenticación",
"backToLogin": "Volver al inicio de sesión"
}
},
"dashboard": {
"title": "Panel",
@@ -4198,6 +4213,10 @@
"title": "Canales",
"description": "Envío por Telegram, ntfy, Web Push, Webhook y correo electrónico.",
"subtitle": "Dónde se entregan los recordatorios y avisos."
},
"security": {
"title": "Seguridad",
"subtitle": "Autenticación de dos factores, códigos de recuperación y claves de acceso."
}
},
"profile": "Perfil",
@@ -4998,6 +5017,60 @@
"dataClear": "Datos restablecidos",
"export": "Exportación de datos",
"other": "Evento de seguridad"
},
"active": "Activo",
"inactive": "Inactivo",
"stepUpRequired": "Por tu seguridad, vuelve a iniciar sesión para cambiar tu segundo factor.",
"useRecoveryCode": "Usar un código de recuperación en su lugar",
"useAuthenticator": "Usar tu aplicación de autenticación en su lugar",
"totp": {
"title": "Aplicación de autenticación",
"description": "Usa un código temporal de una aplicación de autenticación como segundo factor.",
"setUp": "Configurar",
"step1": "Escanea el código QR con tu aplicación de autenticación o introduce el secreto manualmente.",
"step2": "Introduce el código de 6 dígitos que muestra la aplicación para confirmar.",
"qrAlt": "Código QR para configurar la aplicación de autenticación",
"qrUnavailable": "Código QR no disponible",
"manualEntry": "O introduce este secreto manualmente",
"enterCode": "Introduce el código de 6 dígitos",
"verify": "Verificar",
"setupFailed": "No se pudo iniciar la configuración. Inténtalo de nuevo.",
"invalidCode": "Ese código no es válido. Inténtalo de nuevo.",
"disable": "Desactivar",
"disableTitle": "Desactivar la autenticación de dos factores",
"disableConfirm": "Introduce un código actual para desactivar tu segundo factor. Esto también elimina tus códigos de recuperación.",
"currentCode": "Código actual",
"disableFailed": "No se pudo desactivar el segundo factor."
},
"recovery": {
"saveNow": "Guarda ahora estos códigos de recuperación",
"saveNowHint": "Cada código funciona una sola vez. Solo se muestran esta vez: guárdalos en un lugar seguro. Si pierdes tu autenticador, un código de recuperación es la única forma de volver a entrar.",
"download": "Descargar",
"remainingOne": "Queda {count} código de recuperación.",
"remainingOther": "Quedan {count} códigos de recuperación.",
"regenerate": "Regenerar códigos",
"regenFailed": "No se pudieron regenerar los códigos de recuperación.",
"savedIt": "Los he guardado"
},
"keys": {
"title": "Llaves de seguridad",
"description": "Registra una llave de seguridad de hardware o de plataforma como segundo factor.",
"empty": "Aún no hay llaves de seguridad registradas.",
"add": "Añadir llave de seguridad",
"addFailed": "No se pudo registrar la llave de seguridad.",
"rename": "Cambiar nombre",
"renameFailed": "No se pudo cambiar el nombre de la llave de seguridad.",
"remove": "Quitar llave de seguridad",
"removeFailed": "No se pudo quitar la llave de seguridad.",
"removeConfirm": "Esta llave de seguridad dejará de funcionar como segundo factor.",
"lastUsed": "Usada por última vez el {date}",
"neverUsed": "Nunca usada",
"added": "añadida el {date}",
"nameLabel": "Nombre de la llave de seguridad"
},
"passkeyNudge": {
"title": "Añade una clave de acceso para iniciar sesión más rápido",
"body": "Las claves de acceso te permiten iniciar sesión con tu huella, tu cara o el PIN del dispositivo, sin contraseñas que recordar."
}
}
},
+75 -2
View File
@@ -35,7 +35,8 @@
"reportIssue": "Signaler un problème",
"showPassword": "Afficher le mot de passe",
"hidePassword": "Masquer le mot de passe",
"learnMore": "En savoir plus"
"learnMore": "En savoir plus",
"dismiss": "Ignorer"
},
"doctorReport": {
"title": "Rapport de santé",
@@ -264,7 +265,21 @@
"passwordMinLength": "Min. 12 caractères",
"passwordTooShort": "Le mot de passe doit comporter au moins {minLength} caractères.",
"privacyPolicy": "Politique de confidentialité",
"passwordBreached": "Ce mot de passe figure dans des fuites de données connues. Choisissez-en un autre."
"passwordBreached": "Ce mot de passe figure dans des fuites de données connues. Choisissez-en un autre.",
"mfa": {
"title": "Authentification à deux facteurs",
"totpHint": "Saisissez le code à 6 chiffres de votre application d'authentification.",
"recoveryHint": "Saisissez l'un de vos codes de récupération enregistrés.",
"securityKeyHint": "Utilisez votre clé de sécurité enregistrée pour continuer.",
"codeLabel": "Code d'authentification",
"recoveryLabel": "Code de récupération",
"verify": "Vérifier",
"verifyFailed": "Échec de la vérification. Veuillez réessayer.",
"useSecurityKey": "Utiliser une clé de sécurité",
"useRecoveryCode": "Utiliser un code de récupération",
"useAuthenticator": "Utiliser votre application d'authentification",
"backToLogin": "Retour à la connexion"
}
},
"dashboard": {
"title": "Tableau de bord",
@@ -4198,6 +4213,10 @@
"title": "Canaux",
"description": "Distribution via Telegram, ntfy, Web Push, Webhook et e-mail.",
"subtitle": "Où sont envoyés les rappels et alertes."
},
"security": {
"title": "Sécurité",
"subtitle": "Authentification à deux facteurs, codes de récupération et clés d'accès."
}
},
"profile": "Profil",
@@ -4998,6 +5017,60 @@
"dataClear": "Données réinitialisées",
"export": "Export de données",
"other": "Événement de sécurité"
},
"active": "Actif",
"inactive": "Inactif",
"stepUpRequired": "Pour votre sécurité, reconnectez-vous pour modifier votre second facteur.",
"useRecoveryCode": "Utiliser plutôt un code de récupération",
"useAuthenticator": "Utiliser plutôt votre application d'authentification",
"totp": {
"title": "Application d'authentification",
"description": "Utilisez un code temporaire d'une application d'authentification comme second facteur.",
"setUp": "Configurer",
"step1": "Scannez le code QR avec votre application d'authentification, ou saisissez le secret manuellement.",
"step2": "Saisissez le code à 6 chiffres affiché par l'application pour confirmer.",
"qrAlt": "Code QR pour configurer l'application d'authentification",
"qrUnavailable": "Code QR indisponible",
"manualEntry": "Ou saisissez ce secret manuellement",
"enterCode": "Saisissez le code à 6 chiffres",
"verify": "Vérifier",
"setupFailed": "Impossible de démarrer la configuration. Veuillez réessayer.",
"invalidCode": "Ce code n'est pas valide. Veuillez réessayer.",
"disable": "Désactiver",
"disableTitle": "Désactiver l'authentification à deux facteurs",
"disableConfirm": "Saisissez un code actuel pour désactiver votre second facteur. Cela supprime aussi vos codes de récupération.",
"currentCode": "Code actuel",
"disableFailed": "Impossible de désactiver le second facteur."
},
"recovery": {
"saveNow": "Enregistrez ces codes de récupération maintenant",
"saveNowHint": "Chaque code ne fonctionne qu'une fois. Ils ne sont affichés que cette fois — conservez-les en lieu sûr. Si vous perdez votre authentificateur, un code de récupération est le seul moyen de revenir.",
"download": "Télécharger",
"remainingOne": "{count} code de récupération restant.",
"remainingOther": "{count} codes de récupération restants.",
"regenerate": "Régénérer les codes",
"regenFailed": "Impossible de régénérer les codes de récupération.",
"savedIt": "Je les ai enregistrés"
},
"keys": {
"title": "Clés de sécurité",
"description": "Enregistrez une clé de sécurité matérielle ou de plateforme comme second facteur.",
"empty": "Aucune clé de sécurité enregistrée pour le moment.",
"add": "Ajouter une clé de sécurité",
"addFailed": "Impossible d'enregistrer la clé de sécurité.",
"rename": "Renommer",
"renameFailed": "Impossible de renommer la clé de sécurité.",
"remove": "Supprimer la clé de sécurité",
"removeFailed": "Impossible de supprimer la clé de sécurité.",
"removeConfirm": "Cette clé de sécurité ne fonctionnera plus comme second facteur.",
"lastUsed": "Dernière utilisation le {date}",
"neverUsed": "Jamais utilisée",
"added": "ajoutée le {date}",
"nameLabel": "Nom de la clé de sécurité"
},
"passkeyNudge": {
"title": "Ajoutez une clé d'accès pour une connexion plus rapide",
"body": "Les clés d'accès vous permettent de vous connecter avec votre empreinte, votre visage ou le code de votre appareil, sans mot de passe à retenir."
}
}
},
+75 -2
View File
@@ -35,7 +35,8 @@
"reportIssue": "Segnala un problema",
"showPassword": "Mostra password",
"hidePassword": "Nascondi password",
"learnMore": "Scopri di più"
"learnMore": "Scopri di più",
"dismiss": "Ignora"
},
"doctorReport": {
"title": "Rapporto sanitario",
@@ -264,7 +265,21 @@
"passwordMinLength": "Min. 12 caratteri",
"passwordTooShort": "La password deve avere almeno {minLength} caratteri.",
"privacyPolicy": "Informativa sulla privacy",
"passwordBreached": "Questa password compare in violazioni di dati note. Scegline un'altra."
"passwordBreached": "Questa password compare in violazioni di dati note. Scegline un'altra.",
"mfa": {
"title": "Autenticazione a due fattori",
"totpHint": "Inserisci il codice a 6 cifre della tua app di autenticazione.",
"recoveryHint": "Inserisci uno dei tuoi codici di recupero salvati.",
"securityKeyHint": "Usa la tua chiave di sicurezza registrata per continuare.",
"codeLabel": "Codice di autenticazione",
"recoveryLabel": "Codice di recupero",
"verify": "Verifica",
"verifyFailed": "Verifica non riuscita. Riprova.",
"useSecurityKey": "Usa una chiave di sicurezza",
"useRecoveryCode": "Usa un codice di recupero",
"useAuthenticator": "Usa la tua app di autenticazione",
"backToLogin": "Torna all'accesso"
}
},
"dashboard": {
"title": "Cruscotto",
@@ -4198,6 +4213,10 @@
"title": "Canali",
"description": "Recapito tramite Telegram, ntfy, Web Push, Webhook ed e-mail.",
"subtitle": "Dove vengono recapitati promemoria e avvisi."
},
"security": {
"title": "Sicurezza",
"subtitle": "Autenticazione a due fattori, codici di recupero e passkey."
}
},
"profile": "Profilo",
@@ -4998,6 +5017,60 @@
"dataClear": "Dati azzerati",
"export": "Esportazione dati",
"other": "Evento di sicurezza"
},
"active": "Attivo",
"inactive": "Inattivo",
"stepUpRequired": "Per la tua sicurezza, accedi di nuovo per modificare il secondo fattore.",
"useRecoveryCode": "Usa invece un codice di recupero",
"useAuthenticator": "Usa invece la tua app di autenticazione",
"totp": {
"title": "App di autenticazione",
"description": "Usa un codice temporaneo di un'app di autenticazione come secondo fattore.",
"setUp": "Configura",
"step1": "Scansiona il codice QR con la tua app di autenticazione oppure inserisci il segreto manualmente.",
"step2": "Inserisci il codice a 6 cifre mostrato dall'app per confermare.",
"qrAlt": "Codice QR per la configurazione dell'app di autenticazione",
"qrUnavailable": "Codice QR non disponibile",
"manualEntry": "Oppure inserisci questo segreto manualmente",
"enterCode": "Inserisci il codice a 6 cifre",
"verify": "Verifica",
"setupFailed": "Impossibile avviare la configurazione. Riprova.",
"invalidCode": "Questo codice non è valido. Riprova.",
"disable": "Disattiva",
"disableTitle": "Disattiva l'autenticazione a due fattori",
"disableConfirm": "Inserisci un codice attuale per disattivare il secondo fattore. Questo elimina anche i tuoi codici di recupero.",
"currentCode": "Codice attuale",
"disableFailed": "Impossibile disattivare il secondo fattore."
},
"recovery": {
"saveNow": "Salva subito questi codici di recupero",
"saveNowHint": "Ogni codice funziona una sola volta. Vengono mostrati solo questa volta: conservali in un luogo sicuro. Se perdi l'autenticatore, un codice di recupero è l'unico modo per rientrare.",
"download": "Scarica",
"remainingOne": "{count} codice di recupero rimanente.",
"remainingOther": "{count} codici di recupero rimanenti.",
"regenerate": "Rigenera i codici",
"regenFailed": "Impossibile rigenerare i codici di recupero.",
"savedIt": "Li ho salvati"
},
"keys": {
"title": "Chiavi di sicurezza",
"description": "Registra una chiave di sicurezza hardware o di piattaforma come secondo fattore.",
"empty": "Nessuna chiave di sicurezza ancora registrata.",
"add": "Aggiungi chiave di sicurezza",
"addFailed": "Impossibile registrare la chiave di sicurezza.",
"rename": "Rinomina",
"renameFailed": "Impossibile rinominare la chiave di sicurezza.",
"remove": "Rimuovi chiave di sicurezza",
"removeFailed": "Impossibile rimuovere la chiave di sicurezza.",
"removeConfirm": "Questa chiave di sicurezza non funzionerà più come secondo fattore.",
"lastUsed": "Usata l'ultima volta il {date}",
"neverUsed": "Mai usata",
"added": "aggiunta il {date}",
"nameLabel": "Nome della chiave di sicurezza"
},
"passkeyNudge": {
"title": "Aggiungi una passkey per un accesso più rapido",
"body": "Le passkey ti permettono di accedere con impronta, volto o PIN del dispositivo, senza password da ricordare."
}
}
},
+75 -2
View File
@@ -35,7 +35,8 @@
"reportIssue": "Zgłoś problem",
"showPassword": "Pokaż hasło",
"hidePassword": "Ukryj hasło",
"learnMore": "Dowiedz się więcej"
"learnMore": "Dowiedz się więcej",
"dismiss": "Odrzuć"
},
"doctorReport": {
"title": "Raport zdrowotny",
@@ -264,7 +265,21 @@
"passwordMinLength": "Min. 12 znaków",
"passwordTooShort": "Hasło musi mieć co najmniej {minLength} znaków.",
"privacyPolicy": "Polityka prywatności",
"passwordBreached": "To hasło pojawia się w znanych wyciekach danych. Wybierz inne."
"passwordBreached": "To hasło pojawia się w znanych wyciekach danych. Wybierz inne.",
"mfa": {
"title": "Uwierzytelnianie dwuskładnikowe",
"totpHint": "Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej.",
"recoveryHint": "Wprowadź jeden z zapisanych kodów odzyskiwania.",
"securityKeyHint": "Użyj zarejestrowanego klucza bezpieczeństwa, aby kontynuować.",
"codeLabel": "Kod uwierzytelniający",
"recoveryLabel": "Kod odzyskiwania",
"verify": "Zweryfikuj",
"verifyFailed": "Weryfikacja nie powiodła się. Spróbuj ponownie.",
"useSecurityKey": "Użyj klucza bezpieczeństwa",
"useRecoveryCode": "Użyj kodu odzyskiwania",
"useAuthenticator": "Użyj aplikacji uwierzytelniającej",
"backToLogin": "Powrót do logowania"
}
},
"dashboard": {
"title": "Pulpit",
@@ -4198,6 +4213,10 @@
"title": "Kanały",
"description": "Dostarczanie przez Telegram, ntfy, Web Push, Webhook i e-mail.",
"subtitle": "Gdzie dostarczane są przypomnienia i powiadomienia."
},
"security": {
"title": "Bezpieczeństwo",
"subtitle": "Uwierzytelnianie dwuskładnikowe, kody odzyskiwania i klucze dostępu."
}
},
"profile": "Profil",
@@ -4998,6 +5017,60 @@
"dataClear": "Zresetowano dane",
"export": "Eksport danych",
"other": "Zdarzenie zabezpieczeń"
},
"active": "Aktywne",
"inactive": "Nieaktywne",
"stepUpRequired": "Ze względów bezpieczeństwa zaloguj się ponownie, aby zmienić drugi składnik.",
"useRecoveryCode": "Użyj zamiast tego kodu odzyskiwania",
"useAuthenticator": "Użyj zamiast tego aplikacji uwierzytelniającej",
"totp": {
"title": "Aplikacja uwierzytelniająca",
"description": "Użyj kodu czasowego z aplikacji uwierzytelniającej jako drugiego składnika.",
"setUp": "Skonfiguruj",
"step1": "Zeskanuj kod QR aplikacją uwierzytelniającą lub wprowadź sekret ręcznie.",
"step2": "Wprowadź 6-cyfrowy kod wyświetlany przez aplikację, aby potwierdzić.",
"qrAlt": "Kod QR do konfiguracji aplikacji uwierzytelniającej",
"qrUnavailable": "Kod QR niedostępny",
"manualEntry": "Lub wprowadź ten sekret ręcznie",
"enterCode": "Wprowadź 6-cyfrowy kod",
"verify": "Zweryfikuj",
"setupFailed": "Nie udało się rozpocząć konfiguracji. Spróbuj ponownie.",
"invalidCode": "Ten kod jest nieprawidłowy. Spróbuj ponownie.",
"disable": "Wyłącz",
"disableTitle": "Wyłącz uwierzytelnianie dwuskładnikowe",
"disableConfirm": "Wprowadź bieżący kod, aby wyłączyć drugi składnik. Spowoduje to również usunięcie kodów odzyskiwania.",
"currentCode": "Bieżący kod",
"disableFailed": "Nie udało się wyłączyć drugiego składnika."
},
"recovery": {
"saveNow": "Zapisz teraz te kody odzyskiwania",
"saveNowHint": "Każdy kod działa raz. Są wyświetlane tylko teraz — przechowuj je w bezpiecznym miejscu. Jeśli stracisz aplikację uwierzytelniającą, kod odzyskiwania będzie jedyną drogą powrotu.",
"download": "Pobierz",
"remainingOne": "Pozostał {count} kod odzyskiwania.",
"remainingOther": "Pozostało {count} kodów odzyskiwania.",
"regenerate": "Wygeneruj kody ponownie",
"regenFailed": "Nie udało się ponownie wygenerować kodów odzyskiwania.",
"savedIt": "Zapisałem je"
},
"keys": {
"title": "Klucze bezpieczeństwa",
"description": "Zarejestruj sprzętowy lub systemowy klucz bezpieczeństwa jako drugi składnik.",
"empty": "Nie zarejestrowano jeszcze żadnych kluczy bezpieczeństwa.",
"add": "Dodaj klucz bezpieczeństwa",
"addFailed": "Nie udało się zarejestrować klucza bezpieczeństwa.",
"rename": "Zmień nazwę",
"renameFailed": "Nie udało się zmienić nazwy klucza bezpieczeństwa.",
"remove": "Usuń klucz bezpieczeństwa",
"removeFailed": "Nie udało się usunąć klucza bezpieczeństwa.",
"removeConfirm": "Ten klucz bezpieczeństwa nie będzie już działać jako drugi składnik.",
"lastUsed": "Ostatnio użyty {date}",
"neverUsed": "Nigdy nieużywany",
"added": "dodany {date}",
"nameLabel": "Nazwa klucza bezpieczeństwa"
},
"passkeyNudge": {
"title": "Dodaj klucz dostępu, aby logować się szybciej",
"body": "Klucze dostępu pozwalają logować się odciskiem palca, twarzą lub kodem PIN urządzenia — bez hasła do zapamiętania."
}
}
},
@@ -1,7 +1,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: { user: { findFirst: vi.fn() } },
prisma: {
user: { findFirst: vi.fn() },
webauthnMfaCredential: { count: vi.fn().mockResolvedValue(0) },
},
}));
vi.mock("@/lib/auth/password", () => ({
verifyPassword: vi.fn().mockResolvedValue(true),
@@ -75,6 +78,8 @@ const BASE_USER = {
beforeEach(() => {
vi.clearAllMocks();
// Default: no registered security key. Individual tests override as needed.
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
});
describe("login MFA branch", () => {
@@ -99,6 +104,22 @@ describe("login MFA branch", () => {
expect(finishLogin).not.toHaveBeenCalled();
});
it("WebAuthn-only user (no TOTP) → returns mfaRequired with webauthn method", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
...BASE_USER,
totpConfirmedAt: null,
} as never);
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never);
const res = await POST(loginRequest());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.meta.mfaRequired).toBe(true);
expect(body.meta.methods).toEqual(["webauthn"]);
expect(finishLogin).not.toHaveBeenCalled();
});
it("non-MFA user → issues the session via finishLogin, no challenge", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
...BASE_USER,
@@ -8,6 +8,8 @@ vi.mock("@/lib/db", () => ({
user: { findFirst: vi.fn() },
apiToken: { create: vi.fn() },
refreshToken: { create: vi.fn() },
// v1.23 — login checks for a registered second-factor security key.
webauthnMfaCredential: { count: vi.fn().mockResolvedValue(0) },
},
}));
@@ -97,6 +99,7 @@ function makeRequest(headers: Record<string, string> = {}): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.findFirst).mockResolvedValue(FAKE_USER as never);
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
vi.mocked(verifyPassword).mockResolvedValue(true);
vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never);
vi.mocked(prisma.refreshToken.create).mockResolvedValue({
+18 -5
View File
@@ -96,17 +96,30 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
// v1.23 — second-factor gate. The password is correct, but an account with
// a confirmed TOTP factor is NOT yet authenticated: no `Session` row and no
// token bundle is minted here. Instead a single-use, ~5-minute MFA ticket is
// a confirmed second factor (a TOTP secret and/or a registered WebAuthn
// security key) is NOT yet authenticated: no `Session` row and no token
// bundle is minted here. Instead a single-use, ~5-minute MFA ticket is
// returned (the partial state lives in the ticket, never in a half-built
// session) and the client completes the login at `/api/auth/mfa/verify`.
// session) and the client completes the login at `/api/auth/mfa/verify`
// (TOTP / recovery) or `/api/auth/mfa/webauthn/verify` (security key).
//
// Enumeration note: `meta.mfaRequired` is only ever returned AFTER a valid
// password, and the invalid-credentials response above is identical
// regardless of MFA state — an attacker without the password cannot learn
// whether an account has MFA.
if (user.totpConfirmedAt) {
const hasTotp = Boolean(user.totpConfirmedAt);
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
where: { userId: user.id },
});
const hasWebauthn = webauthnKeyCount > 0;
if (hasTotp || hasWebauthn) {
const challenge = await createMfaChallenge(user.id, "login");
// Recovery codes are only ever issued alongside TOTP enrollment, so the
// recovery method is offered exactly when TOTP is active.
const methods: ("totp" | "recovery" | "webauthn")[] = [];
if (hasTotp) methods.push("totp", "recovery");
if (hasWebauthn) methods.push("webauthn");
await auditLog("auth.mfa.challenge", {
userId: user.id,
ipAddress: ip,
@@ -123,7 +136,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
meta: {
mfaRequired: true,
mfaTicket: challenge.ticket,
methods: ["totp", "recovery"],
methods,
},
},
{ status: 200 },
+40
View File
@@ -0,0 +1,40 @@
/**
* GET /api/auth/me/mfa
*
* Second-factor status for the security settings hub: whether TOTP is active,
* how many recovery codes remain, and the registered WebAuthn security keys.
* Cookie-only (`requireCookieAuth`) — managing MFA is never a Bearer surface.
*
* Returns only metadata: no secret, no recovery-code plaintext, no public key.
*/
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { countRemainingRecoveryCodes } from "@/lib/auth/mfa/recovery-codes";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireCookieAuth();
const [recoveryCodesRemaining, webauthn] = await Promise.all([
countRemainingRecoveryCodes(user.id),
prisma.webauthnMfaCredential.findMany({
where: { userId: user.id },
select: { id: true, name: true, createdAt: true, lastUsedAt: true },
orderBy: { createdAt: "desc" },
}),
]);
annotate({ action: { name: "auth.mfa.status" } });
return apiSuccess({
totp: { enabled: Boolean(user.totpConfirmedAt) },
recoveryCodesRemaining,
webauthn,
// The security hub also drives the passkey-upgrade nudge from this one
// status read, so it knows whether the user has already dismissed it.
passkeyNudgeDismissed: Boolean(user.passkeyUpgradeNudgeDismissed),
});
});
@@ -0,0 +1,95 @@
/**
* PATCH /api/auth/me/mfa/webauthn/[id] — rename a registered security key.
* DELETE /api/auth/me/mfa/webauthn/[id] — remove one (step-up gated).
*
* Rename is cookie-authenticated (non-destructive). Removal is a sensitive
* action: it is gated on a fresh second-factor step-up (`requireFreshMfa`,
* cookie-only — a Bearer token can never satisfy it).
*/
import { NextRequest } from "next/server";
import {
apiHandler,
requireCookieAuth,
requireFreshMfa,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { mfaWebauthnRenameSchema } from "@/lib/validations/mfa";
export const dynamic = "force-dynamic";
export const PATCH = apiHandler(
async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user } = await requireCookieAuth();
const { id } = await params;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = mfaWebauthnRenameSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
}
const existing = await prisma.webauthnMfaCredential.findUnique({
where: { id },
select: { userId: true },
});
if (!existing || existing.userId !== user.id) {
return apiError("Security key not found", 404);
}
const updated = await prisma.webauthnMfaCredential.update({
where: { id },
data: { name: parsed.data.name },
select: { id: true, name: true, createdAt: true, lastUsedAt: true },
});
annotate({ action: { name: "auth.mfa.webauthn.rename" } });
return apiSuccess(updated);
},
);
export const DELETE = apiHandler(
async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
// Step-up gate first — throws StepUpRequiredError (401 + errorCode) if the
// session is not freshly second-factor-verified.
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
const { id } = await params;
const existing = await prisma.webauthnMfaCredential.findUnique({
where: { id },
select: { userId: true, name: true },
});
if (!existing || existing.userId !== user.id) {
return apiError("Security key not found", 404);
}
await prisma.webauthnMfaCredential.delete({ where: { id } });
await auditLog("auth.mfa.webauthn.remove", {
userId: user.id,
ipAddress: getClientIp(request),
details: { credentialId: id, name: existing.name },
});
annotate({ action: { name: "auth.mfa.webauthn.remove" } });
return apiSuccess({ success: true });
},
);
@@ -0,0 +1,141 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: (fn: unknown) => fn,
requireCookieAuth: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: { webauthnMfaCredential: { create: vi.fn() } },
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/auth/mfa/webauthn", () => ({
createMfaRegistrationOptions: vi.fn(),
verifyMfaRegistration: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
import { POST as REGISTER_OPTIONS } from "../options/route";
import { POST as REGISTER_VERIFY } from "../verify/route";
import { requireCookieAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import {
createMfaRegistrationOptions,
verifyMfaRegistration,
} from "@/lib/auth/mfa/webauthn";
const USER = { id: "user-1", username: "u", email: "u@example.com" };
function verifyRequest(body: Record<string, unknown>): NextRequest {
return new NextRequest(
"http://localhost/api/auth/me/mfa/webauthn/register/verify",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
},
);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(requireCookieAuth).mockResolvedValue({ user: USER } as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
});
describe("POST /api/auth/me/mfa/webauthn/register/options", () => {
it("returns ceremony options + challenge id", async () => {
vi.mocked(createMfaRegistrationOptions).mockResolvedValue({
options: { challenge: "abc" },
challengeId: "ch-1",
} as never);
const res = await REGISTER_OPTIONS();
const body = (await res.json()) as {
data: { options: unknown; challengeId: string };
};
expect(res.status).toBe(200);
expect(body.data.challengeId).toBe("ch-1");
expect(createMfaRegistrationOptions).toHaveBeenCalledWith(
"user-1",
"u@example.com",
);
});
});
describe("POST /api/auth/me/mfa/webauthn/register/verify", () => {
const credential = {
id: "cred-1",
rawId: "cred-1",
type: "public-key",
response: { transports: ["usb"] },
};
it("stores the credential when verification succeeds", async () => {
vi.mocked(verifyMfaRegistration).mockResolvedValue({
verified: true,
registrationInfo: {
credential: {
id: "cred-1",
publicKey: new Uint8Array([1, 2, 3]),
counter: 0,
},
},
} as never);
vi.mocked(prisma.webauthnMfaCredential.create).mockResolvedValue({
id: "row-1",
name: "My key",
createdAt: new Date(),
lastUsedAt: null,
} as never);
const res = await REGISTER_VERIFY(
verifyRequest({ challengeId: "ch-1", credential, name: "My key" }),
);
const body = (await res.json()) as { data: { id: string; name: string } };
expect(res.status).toBe(200);
expect(body.data.id).toBe("row-1");
expect(verifyMfaRegistration).toHaveBeenCalledWith(
"ch-1",
"user-1",
credential,
);
const createArgs = vi.mocked(prisma.webauthnMfaCredential.create).mock
.calls[0][0];
expect(createArgs.data.userId).toBe("user-1");
expect(createArgs.data.credentialId).toBe("cred-1");
expect(createArgs.data.name).toBe("My key");
});
it("returns 400 when verification fails", async () => {
vi.mocked(verifyMfaRegistration).mockResolvedValue({
verified: false,
} as never);
const res = await REGISTER_VERIFY(
verifyRequest({ challengeId: "ch-1", credential }),
);
expect(res.status).toBe(400);
expect(prisma.webauthnMfaCredential.create).not.toHaveBeenCalled();
});
it("returns 422 for a malformed body", async () => {
const res = await REGISTER_VERIFY(verifyRequest({ challengeId: "ch-1" }));
expect(res.status).toBe(422);
});
});
@@ -0,0 +1,40 @@
/**
* POST /api/auth/me/mfa/webauthn/register/options
*
* Begin registering a WebAuthn security key as a second factor. Cookie-only —
* an API token can never enrol MFA. Returns the SimpleWebAuthn creation
* options + the server-issued challenge id to present back at /register/verify.
*/
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
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 () => {
const { user } = await requireCookieAuth();
const rl = await checkRateLimit(
`mfa:webauthn:register:${user.id}`,
10,
15 * 60 * 1000,
);
if (!rl.allowed) {
const res = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
res.headers.set(k, v);
}
return res;
}
const { options, challengeId } = await createMfaRegistrationOptions(
user.id,
user.email ?? user.username,
);
annotate({ action: { name: "auth.mfa.webauthn.register-options" } });
return apiSuccess({ options, challengeId });
});
@@ -0,0 +1,69 @@
/**
* POST /api/auth/me/mfa/webauthn/register/verify
*
* Finish registering a WebAuthn security key as a second factor. Cookie-only.
* Verifies the attestation against the user-bound challenge and stores the
* credential in `WebauthnMfaCredential` (kept separate from primary passkeys).
*/
import { NextRequest } from "next/server";
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { verifyMfaRegistration } from "@/lib/auth/mfa/webauthn";
import { mfaWebauthnRegisterVerifySchema } from "@/lib/validations/mfa";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireCookieAuth();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = mfaWebauthnRegisterVerifySchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
}
const { challengeId, credential, name } = parsed.data;
const verification = await verifyMfaRegistration(
challengeId,
user.id,
credential,
);
if (!verification.verified || !verification.registrationInfo) {
return apiError("Security key verification failed", 400);
}
const { registrationInfo } = verification;
const created = await prisma.webauthnMfaCredential.create({
data: {
userId: user.id,
name: name ?? "Security key",
credentialId: registrationInfo.credential.id,
credentialPublicKey: Buffer.from(registrationInfo.credential.publicKey),
counter: BigInt(registrationInfo.credential.counter),
transports:
((credential.response as Record<string, unknown> | undefined)
?.transports as string[]) ?? [],
},
select: { id: true, name: true, createdAt: true, lastUsedAt: true },
});
await auditLog("auth.mfa.webauthn.register", {
userId: user.id,
ipAddress: getClientIp(request),
});
annotate({ action: { name: "auth.mfa.webauthn.register" } });
return apiSuccess(created);
});
@@ -0,0 +1,24 @@
/**
* POST /api/auth/me/passkey-nudge/dismiss
*
* Permanently dismiss the "add a passkey" upgrade nudge for the calling user.
* Cookie-only; idempotent. Sets `User.passkeyUpgradeNudgeDismissed = true`.
*/
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async () => {
const { user } = await requireCookieAuth();
await prisma.user.update({
where: { id: user.id },
data: { passkeyUpgradeNudgeDismissed: true },
});
annotate({ action: { name: "auth.passkey.nudge.dismiss" } });
return apiSuccess({ dismissed: true });
});
@@ -0,0 +1,163 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: (fn: unknown) => fn,
}));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/auth/mfa/challenge", () => ({
loadActiveChallenge: vi.fn(),
recordChallengeFailure: vi.fn().mockResolvedValue({ exhausted: false }),
claimChallenge: vi.fn(),
}));
vi.mock("@/lib/auth/mfa/webauthn", () => ({
verifyMfaAuthentication: vi.fn(),
}));
vi.mock("@/lib/auth/login-response", () => ({
finishLogin: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import {
loadActiveChallenge,
claimChallenge,
recordChallengeFailure,
} from "@/lib/auth/mfa/challenge";
import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn";
import { finishLogin } from "@/lib/auth/login-response";
function makeRequest(body: Record<string, unknown>): NextRequest {
return new NextRequest("http://localhost/api/auth/mfa/webauthn/verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
const VALID_BODY = {
mfaTicket: "ticket-1",
challengeId: "ch-1",
credential: {
id: "cred-1",
rawId: "cred-1",
type: "public-key",
response: {},
},
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
ip: "203.0.113.1",
} as never);
vi.mocked(loadActiveChallenge).mockResolvedValue({
id: "mfa-1",
userId: "user-1",
kind: "login",
attempts: 0,
expiresAt: new Date(Date.now() + 60_000),
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user-1",
username: "u",
onboardingCompletedAt: new Date(),
} as never);
vi.mocked(recordChallengeFailure).mockResolvedValue({
exhausted: false,
attempts: 1,
} as never);
vi.mocked(finishLogin).mockResolvedValue(
new Response(JSON.stringify({ data: { user: { id: "user-1" } } }), {
status: 200,
}),
);
});
describe("POST /api/auth/mfa/webauthn/verify", () => {
it("finishes login when the assertion verifies and the ticket is claimed", async () => {
vi.mocked(verifyMfaAuthentication).mockResolvedValue(true as never);
vi.mocked(claimChallenge).mockResolvedValue(true as never);
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(200);
expect(verifyMfaAuthentication).toHaveBeenCalledWith(
"ch-1",
"user-1",
VALID_BODY.credential,
);
expect(claimChallenge).toHaveBeenCalledWith("mfa-1");
expect(finishLogin).toHaveBeenCalledWith(
expect.objectContaining({
mfaVerified: true,
source: "mfa.webauthn.verify",
}),
);
});
it("rejects with 401 and burns an attempt when the assertion fails", async () => {
vi.mocked(verifyMfaAuthentication).mockResolvedValue(false as never);
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(401);
expect(recordChallengeFailure).toHaveBeenCalledWith("mfa-1");
expect(claimChallenge).not.toHaveBeenCalled();
expect(finishLogin).not.toHaveBeenCalled();
});
it("rejects with 401 for an unknown / expired ticket", async () => {
vi.mocked(loadActiveChallenge).mockResolvedValue(null as never);
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(401);
expect(verifyMfaAuthentication).not.toHaveBeenCalled();
});
it("does not mint a session if the ticket claim is lost (concurrent)", async () => {
vi.mocked(verifyMfaAuthentication).mockResolvedValue(true as never);
vi.mocked(claimChallenge).mockResolvedValue(false as never);
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(401);
expect(finishLogin).not.toHaveBeenCalled();
});
it("returns 429 when rate limited", async () => {
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: false,
ip: "203.0.113.1",
} as never);
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(429);
});
});
@@ -0,0 +1,70 @@
/**
* POST /api/auth/mfa/webauthn/verify/options
*
* Begin a mid-login second-factor security-key assertion. The caller presents
* the opaque `mfaTicket` from the `meta.mfaRequired` login response; the server
* resolves the password-identified user and returns assertion options scoped to
* that user's registered MFA security keys (never an empty allow-list — a
* non-resident credential is not discoverable).
*
* Anonymous surface (no session yet), rate-limited per IP. A single generic
* 401 covers every unknown / expired / consumed ticket so a guesser learns
* nothing.
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { loadActiveChallenge } from "@/lib/auth/mfa/challenge";
import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
import { mfaWebauthnLoginOptionsSchema } from "@/lib/validations/mfa";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async (request: NextRequest) => {
const rl = await checkAuthSurfaceRateLimit(
request,
"auth:mfa-verify",
10,
15 * 60 * 1000,
);
if (!rl.allowed) {
return apiError("Too many attempts. Please try again later.", 429, {
headers: rateLimitHeaders(rl),
});
}
await ensureDbCompatibility();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = mfaWebauthnLoginOptionsSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
}
const challenge = await loadActiveChallenge(parsed.data.mfaTicket);
if (!challenge) {
annotate({ action: { name: "auth.mfa.verify.invalid_ticket" } });
return apiError("Invalid or expired challenge", 401);
}
const result = await createMfaAuthenticationOptions(challenge.userId);
if (!result) {
// No registered security key for this account — the client should fall
// back to a TOTP / recovery code.
annotate({ action: { name: "auth.mfa.webauthn.no_key" } });
return apiError("No security key registered", 409);
}
annotate({ action: { name: "auth.mfa.webauthn.verify-options" } });
return apiSuccess({
options: result.options,
challengeId: result.challengeId,
});
});
@@ -0,0 +1,127 @@
/**
* POST /api/auth/mfa/webauthn/verify
*
* Complete a mid-login second-factor login challenge with a WebAuthn security
* key. The caller presents the login `mfaTicket` plus the assertion from
* /verify/options. On success the SAME session/token bundle the password path
* issues is returned (web → cookie; native → access+refresh), with the session
* stamped `mfaVerifiedAt`.
*
* Security invariants mirror /api/auth/mfa/verify (the TOTP path):
* - No session/token exists until the assertion verifies — the partial state
* lives in the ticket, never in a half-built session.
* - The ticket is claimed once, atomically, only AFTER the assertion verifies.
* - A failed assertion increments the ticket attempt counter and burns it at
* the cap. Anonymous surface is rate-limited per IP on top of the cap.
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { finishLogin } from "@/lib/auth/login-response";
import {
loadActiveChallenge,
recordChallengeFailure,
claimChallenge,
} from "@/lib/auth/mfa/challenge";
import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn";
import { mfaWebauthnLoginVerifySchema } from "@/lib/validations/mfa";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async (request: NextRequest) => {
const rl = await checkAuthSurfaceRateLimit(
request,
"auth:mfa-verify",
10,
15 * 60 * 1000,
);
const ip = rl.ip ?? "unknown";
if (!rl.allowed) {
return apiError("Too many attempts. Please try again later.", 429, {
headers: rateLimitHeaders(rl),
});
}
await ensureDbCompatibility();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = mfaWebauthnLoginVerifySchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
}
const { mfaTicket, challengeId, credential } = parsed.data;
const challenge = await loadActiveChallenge(mfaTicket);
if (!challenge) {
annotate({ action: { name: "auth.mfa.verify.invalid_ticket" } });
return apiError("Invalid or expired challenge", 401);
}
const user = await prisma.user.findUnique({
where: { id: challenge.userId },
select: { id: true, username: true, onboardingCompletedAt: true },
});
if (!user) {
await recordChallengeFailure(challenge.id);
return apiError("Invalid or expired challenge", 401);
}
let verified = false;
try {
verified = await verifyMfaAuthentication(challengeId, user.id, credential);
} catch {
// A malformed / expired WebAuthn challenge is a failed attempt, not a 500.
verified = false;
}
if (!verified) {
const { exhausted } = await recordChallengeFailure(challenge.id);
await auditLog("auth.mfa.failed", {
userId: user.id,
ipAddress: ip,
details: { stage: "login", method: "webauthn", exhausted },
});
annotate({
action: { name: "auth.mfa.failed" },
meta: { method: "webauthn", exhausted },
});
return apiError("Security key verification failed", 401);
}
// Assertion verified — claim the ticket atomically. Only the single winning
// caller proceeds to mint a session.
const claimed = await claimChallenge(challenge.id);
if (!claimed) {
annotate({ action: { name: "auth.mfa.verify.claim_lost" } });
return apiError("Invalid or expired challenge", 401);
}
await auditLog("auth.mfa.verified", {
userId: user.id,
ipAddress: ip,
details: { method: "webauthn" },
});
annotate({
action: { name: "auth.mfa.verified" },
meta: { method: "webauthn" },
});
const ua = request.headers.get("user-agent");
return finishLogin({
user,
request,
ip,
userAgent: ua,
source: "mfa.webauthn.verify",
mfaVerified: true,
});
});
+54 -1
View File
@@ -1,9 +1,62 @@
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { webauthnKeyNameSchema } from "@/lib/validations/mfa";
import { z } from "zod/v4";
const passkeyRenameSchema = z.object({ name: webauthnKeyNameSchema });
export const PATCH = apiHandler(
async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user } = await requireAuth();
const { id } = await params;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = passkeyRenameSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
}
const passkey = await prisma.passkey.findUnique({
where: { id },
select: { userId: true },
});
if (!passkey || passkey.userId !== user.id) {
return apiError("Passkey not found", 404);
}
const updated = await prisma.passkey.update({
where: { id },
data: { name: parsed.data.name },
select: {
id: true,
name: true,
credentialDeviceType: true,
credentialBackedUp: true,
createdAt: true,
lastUsedAt: true,
},
});
annotate({ action: { name: "auth.passkey.rename" } });
return apiSuccess(updated);
},
);
export const DELETE = apiHandler(
async (
+1
View File
@@ -16,6 +16,7 @@ export const GET = apiHandler(async () => {
credentialDeviceType: true,
credentialBackedUp: true,
createdAt: true,
lastUsedAt: true,
},
orderBy: { createdAt: "desc" },
});
+214 -113
View File
@@ -1,6 +1,6 @@
"use client";
import { useId, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { KeyRound, Lock, Loader2 } from "lucide-react";
@@ -13,7 +13,13 @@ import { Separator } from "@/components/ui/separator";
import { describePasskeyError } from "@/lib/passkey-errors";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
import {
ApiError,
apiFetchEnvelope,
apiGet,
apiPost,
} from "@/lib/api/api-fetch";
import { MfaLoginStep, type MfaMethod } from "@/components/auth/mfa-login-step";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
@@ -27,6 +33,15 @@ export default function LoginPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// v1.23 — second step of login when the password response carries
// `meta.mfaRequired`. Holds the opaque ticket + the factors the account can
// complete the challenge with.
const [mfaChallenge, setMfaChallenge] = useState<{
ticket: string;
methods: MfaMethod[];
} | null>(null);
// Guards the conditional-UI passkey autofill so it starts at most once.
const autofillStarted = useRef(false);
// v1.4.27 MB3 — explicit error-region id so the email + password
// inputs reference the banner via `aria-describedby`. Screen readers
// pair the error with the field instead of announcing it as a
@@ -112,7 +127,28 @@ export default function LoginPage() {
setLoading(true);
try {
await apiPost("/api/auth/login", { email, password });
// Read the envelope `meta` directly: an MFA account gets HTTP 200 with
// `data: null` + `meta.mfaRequired`, not an error and not a session.
const { meta } = await apiFetchEnvelope<
unknown,
{
mfaRequired?: boolean;
mfaTicket?: string;
methods?: MfaMethod[];
}
>("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (meta?.mfaRequired && meta.mfaTicket) {
setMfaChallenge({
ticket: meta.mfaTicket,
methods: meta.methods ?? ["totp", "recovery"],
});
return;
}
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
navigateAfterLogin();
@@ -127,6 +163,50 @@ export default function LoginPage() {
}
}
// v1.23 — passkey conditional-UI autofill. When the browser supports it,
// arm a discoverable-credential assertion bound to the username field so a
// returning user can pick their passkey straight from the autofill prompt.
// Best-effort: any abort (the user types a password instead) is swallowed.
useEffect(() => {
if (autofillStarted.current) return;
autofillStarted.current = true;
let cancelled = false;
(async () => {
try {
const { browserSupportsWebAuthnAutofill, startAuthentication } =
await import("@simplewebauthn/browser");
if (!(await browserSupportsWebAuthnAutofill())) return;
const { options, challengeId } = await apiPost<{
options: Parameters<typeof startAuthentication>[0]["optionsJSON"];
challengeId: string;
}>("/api/auth/passkey/login-options");
const credential = await startAuthentication({
optionsJSON: options,
useBrowserAutofill: true,
});
if (cancelled) return;
await apiPost("/api/auth/passkey/login-verify", {
challengeId,
credential,
});
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
navigateAfterLogin();
} catch {
// Autofill aborts (no passkey, user dismisses, password path taken)
// are normal — stay silent and let the explicit buttons drive login.
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="flex w-full max-w-sm flex-col gap-4">
{/* v1.4.27 MB7 / CF-61 — drop `p-8` to `p-6 sm:p-8` so the
@@ -143,131 +223,152 @@ export default function LoginPage() {
</div>
</div>
<div className="mt-8 space-y-4">
{/* Phase A5 / B-mobile: bumped from default size (h-9, 36px)
{mfaChallenge ? (
<div className="mt-8">
<MfaLoginStep
mfaTicket={mfaChallenge.ticket}
methods={mfaChallenge.methods}
onSuccess={async () => {
await queryClient.invalidateQueries({
queryKey: queryKeys.auth(),
});
navigateAfterLogin();
}}
onCancel={() => {
setMfaChallenge(null);
setError(null);
}}
/>
</div>
) : (
<div className="mt-8 space-y-4">
{/* 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>
<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
variant="outline"
onClick={handlePasskeyLogin}
className="min-h-11 w-full"
size="lg"
onClick={() => setMode("password")}
disabled={loading}
>
<Lock className="h-4 w-4" />
{t("auth.loginWithPassword")}
{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>
) : (
<form onSubmit={handlePasswordLogin} className="space-y-3">
<div className="space-y-2">
<Label htmlFor="email">{t("auth.emailOrUsername")}</Label>
<Input
id="email"
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="username"
inputMode="email"
enterKeyHint="next"
autoCapitalize="none"
spellCheck={false}
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
placeholder={t("auth.emailOrUsernamePlaceholder")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">{t("auth.password")}</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
enterKeyHint="go"
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
placeholder="********"
/>
</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
type="submit"
variant="outline"
className="min-h-11 w-full"
size="lg"
disabled={loading}
onClick={() => setMode("password")}
>
{loading && mode === "password" ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Lock className="h-4 w-4" />
)}
{t("auth.login")}
<Lock className="h-4 w-4" />
{t("auth.loginWithPassword")}
</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>
</form>
)}
) : (
<form onSubmit={handlePasswordLogin} className="space-y-3">
<div className="space-y-2">
<Label htmlFor="email">{t("auth.emailOrUsername")}</Label>
<Input
id="email"
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
// `webauthn` token arms the conditional-UI passkey
// autofill prompt on this field (see the mount effect).
autoComplete="username webauthn"
inputMode="email"
enterKeyHint="next"
autoCapitalize="none"
spellCheck={false}
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
placeholder={t("auth.emailOrUsernamePlaceholder")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">{t("auth.password")}</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
enterKeyHint="go"
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
placeholder="********"
/>
</div>
<Button
type="submit"
className="min-h-11 w-full"
size="lg"
disabled={loading}
>
{loading && mode === "password" ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Lock className="h-4 w-4" />
)}
{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>
</form>
)}
{error && (
<div
id={errorId}
role="alert"
aria-live="polite"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{error}
</div>
)}
{registrationEnabled === true && (
<p className="text-muted-foreground text-center text-xs">
{t("auth.noAccount")}{" "}
<Link
href="/auth/register"
// axe-core `link-in-text-block` requires a visible
// distinction beyond colour alone (WCAG 1.4.1). Forcing
// the underline always-on instead of `hover:underline`
// satisfies the rule without changing the visual after
// hover.
className="text-primary underline"
{error && (
<div
id={errorId}
role="alert"
aria-live="polite"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{t("auth.register")}
</Link>
</p>
)}
</div>
{error}
</div>
)}
{registrationEnabled === true && (
<p className="text-muted-foreground text-center text-xs">
{t("auth.noAccount")}{" "}
<Link
href="/auth/register"
// axe-core `link-in-text-block` requires a visible
// distinction beyond colour alone (WCAG 1.4.1). Forcing
// the underline always-on instead of `hover:underline`
// satisfies the rule without changing the visual after
// hover.
className="text-primary underline"
>
{t("auth.register")}
</Link>
</p>
)}
</div>
)}
</div>
{/* v1.4.26 — discoverable privacy link below the login card.
App Store reviewers and first-time visitors need a one-click
+2
View File
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
import type { JSX } from "react";
import { AccountSection } from "@/components/settings/account-section";
import { SecuritySection } from "@/components/settings/security-section";
import { AboutSection } from "@/components/settings/about-section";
import { AdvancedSection } from "@/components/settings/advanced-section";
import { AiSection } from "@/components/settings/ai-section";
@@ -55,6 +56,7 @@ const SECTION_COMPONENTS: Record<
() => JSX.Element | null
> = {
account: AccountSection,
security: SecuritySection,
modules: ModulesSection,
about: AboutSection,
ai: AiSection,
@@ -0,0 +1,54 @@
/**
* MfaLoginStep rendering — the second step of login shown when the password
* response carried `meta.mfaRequired`. Pins which controls render for each
* factor mix (TOTP+recovery, TOTP+webauthn, webauthn-only).
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({ t: (key: string) => key }),
}));
import { MfaLoginStep } from "../mfa-login-step";
function render(methods: ("totp" | "recovery" | "webauthn")[]) {
return renderToStaticMarkup(
<MfaLoginStep
mfaTicket="ticket-1"
methods={methods}
onSuccess={() => {}}
onCancel={() => {}}
/>,
);
}
describe("MfaLoginStep", () => {
it("renders the code entry + recovery toggle for a TOTP account", () => {
const html = render(["totp", "recovery"]);
expect(html).toContain("mfa-login-step");
expect(html).toContain("auth.mfa.codeLabel");
expect(html).toContain("auth.mfa.verify");
expect(html).toContain("auth.mfa.useRecoveryCode");
// No security-key button when the account has no registered key.
expect(html).not.toContain("auth.mfa.useSecurityKey");
});
it("renders the security-key button alongside TOTP when both are available", () => {
const html = render(["totp", "recovery", "webauthn"]);
expect(html).toContain("auth.mfa.codeLabel");
expect(html).toContain("auth.mfa.useSecurityKey");
});
it("leads with the security key for a WebAuthn-only account", () => {
const html = render(["webauthn"]);
expect(html).toContain("auth.mfa.useSecurityKey");
// No code form and no recovery toggle without TOTP.
expect(html).not.toContain("auth.mfa.codeLabel");
expect(html).not.toContain("auth.mfa.useRecoveryCode");
});
it("always offers a way back to the password step", () => {
expect(render(["totp"])).toContain("auth.mfa.backToLogin");
});
});
+208
View File
@@ -0,0 +1,208 @@
"use client";
import { useId, useState } from "react";
import { KeyRound, Loader2, Lock, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { describePasskeyError } from "@/lib/passkey-errors";
export type MfaMethod = "totp" | "recovery" | "webauthn";
/**
* Second step of login when the password response carried `meta.mfaRequired`.
*
* Renders the TOTP code entry by default, with a switch to a recovery code and
* — when the account has a registered security key — a "use a security key"
* button that runs the scoped WebAuthn assertion. Each path posts back to the
* matching verify endpoint and, on success, calls `onSuccess` (the parent
* finishes the login exactly like a normal sign-in).
*/
export function MfaLoginStep({
mfaTicket,
methods,
onSuccess,
onCancel,
}: {
mfaTicket: string;
methods: MfaMethod[];
onSuccess: () => void;
onCancel: () => void;
}) {
const { t } = useTranslations();
const codeFieldId = useId();
const errorId = useId();
const hasTotp = methods.includes("totp");
const hasWebauthn = methods.includes("webauthn");
// Default to a code entry when TOTP is available; otherwise lead with the
// security-key path (a WebAuthn-only account).
const [useRecovery, setUseRecovery] = useState(false);
const [code, setCode] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submitCode(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
await apiPost("/api/auth/mfa/verify", {
mfaTicket,
method: useRecovery ? "recovery" : "totp",
code: code.trim(),
});
onSuccess();
} catch (err) {
setError(
err instanceof ApiError && err.message
? err.message
: t("auth.mfa.verifyFailed"),
);
} finally {
setLoading(false);
}
}
async function submitSecurityKey() {
setError(null);
setLoading(true);
try {
const { startAuthentication } = await import("@simplewebauthn/browser");
const { options, challengeId } = await apiPost<{
options: Parameters<typeof startAuthentication>[0]["optionsJSON"];
challengeId: string;
}>("/api/auth/mfa/webauthn/verify/options", { mfaTicket });
const credential = await startAuthentication({ optionsJSON: options });
await apiPost("/api/auth/mfa/webauthn/verify", {
mfaTicket,
challengeId,
credential,
});
onSuccess();
} catch (err) {
if (err instanceof ApiError) {
setError(err.message || t("auth.mfa.verifyFailed"));
} else {
const { key, params } = describePasskeyError(err);
setError(t(key, params));
}
} finally {
setLoading(false);
}
}
return (
<div className="space-y-4" data-testid="mfa-login-step">
<div className="flex flex-col items-center gap-2 text-center">
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
<ShieldCheck className="text-primary h-5 w-5" />
</div>
<h2 className="text-lg font-semibold">{t("auth.mfa.title")}</h2>
<p className="text-muted-foreground text-sm">
{useRecovery
? t("auth.mfa.recoveryHint")
: hasTotp
? t("auth.mfa.totpHint")
: t("auth.mfa.securityKeyHint")}
</p>
</div>
{(hasTotp || useRecovery) && (
<form onSubmit={submitCode} className="space-y-3">
<div className="space-y-2">
<Label htmlFor={codeFieldId}>
{useRecovery
? t("auth.mfa.recoveryLabel")
: t("auth.mfa.codeLabel")}
</Label>
<Input
id={codeFieldId}
value={code}
onChange={(e) =>
setCode(
useRecovery
? e.target.value
: e.target.value.replace(/\D/g, "").slice(0, 6),
)
}
inputMode={useRecovery ? "text" : "numeric"}
autoComplete="one-time-code"
autoFocus
placeholder={useRecovery ? "XXXXX-XXXXX" : "123456"}
className="font-mono tracking-widest"
aria-invalid={!!error || undefined}
aria-describedby={error ? errorId : undefined}
/>
</div>
<Button
type="submit"
className="min-h-11 w-full"
size="lg"
disabled={loading || code.trim().length < 6}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Lock className="h-4 w-4" />
)}
{t("auth.mfa.verify")}
</Button>
</form>
)}
{hasWebauthn && (
<Button
type="button"
variant={hasTotp ? "outline" : "default"}
className="min-h-11 w-full"
size="lg"
onClick={submitSecurityKey}
disabled={loading}
>
<KeyRound className="h-4 w-4" />
{t("auth.mfa.useSecurityKey")}
</Button>
)}
{hasTotp && (
<button
type="button"
onClick={() => {
setUseRecovery((v) => !v);
setCode("");
setError(null);
}}
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
>
{useRecovery
? t("auth.mfa.useAuthenticator")
: t("auth.mfa.useRecoveryCode")}
</button>
)}
{error && (
<div
id={errorId}
role="alert"
aria-live="polite"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{error}
</div>
)}
<button
type="button"
onClick={onCancel}
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
>
{t("auth.mfa.backToLogin")}
</button>
</div>
);
}
@@ -99,6 +99,7 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// health record belongs next to the health-record export.
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
"account",
"security",
"modules",
"integrations",
"channels",
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { KeyRound, Loader2, Save, Shield, User } from "lucide-react";
import { Loader2, Save, Shield, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -21,7 +21,6 @@ import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
import { useTranslations } from "@/lib/i18n/context";
import { describePasskeyError } from "@/lib/passkey-errors";
import { AboutMeSection } from "@/components/settings/about-me-section";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -37,7 +36,6 @@ import {
statusText,
type StatusMessage,
} from "./account-section-utils";
import { PasskeyListSection } from "./passkey-list-section";
import { AvatarSection } from "./avatar-section";
import { SecuritySessionsCard } from "@/components/settings/security-sessions-card";
import { SecurityActivityCard } from "@/components/settings/security-activity-card";
@@ -79,12 +77,8 @@ export function AccountSection() {
>(null);
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
// Passkey registration state.
const [passkeyLoading, setPasskeyLoading] = useState(false);
const [passkeyMsg, setPasskeyMsg] = useState<StatusMessage | null>(null);
const [passkeyMsgType, setPasskeyMsgType] = useState<
"success" | "error" | null
>(null);
// v1.23 — passkey + second-factor management moved to the dedicated
// /settings/security hub so "how I secure my account" reads as one place.
// Auth gate — push back to login if the user is unauthenticated. Effect
// intentionally only navigates (never sets state in a way that re-runs the
@@ -179,56 +173,6 @@ export function AccountSection() {
setSaving(false);
}
async function handleAddPasskey() {
setPasskeyLoading(true);
setPasskeyMsg(null);
setPasskeyMsgType(null);
try {
const optRes = await apiFetchRaw("/api/auth/passkey/register-options", {
method: "POST",
});
if (!optRes.ok) {
setPasskeyMsg({ key: "settings.passkeyOptionsError" });
setPasskeyMsgType("error");
setPasskeyLoading(false);
return;
}
const optJson = await optRes.json();
const { options, challengeId } = optJson.data;
const { startRegistration } = await import("@simplewebauthn/browser");
const credential = await startRegistration({ optionsJSON: options });
const verifyRes = await apiFetchRaw("/api/auth/passkey/register-verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ challengeId, credential }),
});
if (verifyRes.ok) {
setPasskeyMsg({ key: "settings.passkeyAdded" });
setPasskeyMsgType("success");
} else {
const verifyJson = await verifyRes.json();
setPasskeyMsg(
verifyJson.error
? { text: verifyJson.error }
: { key: "settings.passkeyRegistrationFailed" },
);
setPasskeyMsgType("error");
}
} catch (err) {
const { key, params } = describePasskeyError(err);
setPasskeyMsg({ key, params });
setPasskeyMsgType("error");
} finally {
setPasskeyLoading(false);
}
}
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
setPasswordSaving(true);
@@ -521,43 +465,6 @@ export function AccountSection() {
reachable. */}
<CycleTrackingCard isAuthenticated={isAuthenticated} />
{/* Passkeys card */}
<SettingsCard>
<SettingsCardHeader
icon={Shield}
title={t("settings.passkeys")}
className="mb-4"
/>
<div className="pl-7">
<PasskeyListSection isAuthenticated={isAuthenticated} />
</div>
<div className="mt-4 flex justify-end pl-7">
<Button
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={handleAddPasskey}
disabled={passkeyLoading}
>
{passkeyLoading ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="h-4 w-4" />
)}
{t("settings.addPasskey")}
</Button>
</div>
{passkeyMsg && (
<p
role="alert"
className={`mt-2 text-right text-sm ${
passkeyMsgType === "success" ? "text-success" : "text-destructive"
}`}
>
{statusText(passkeyMsg, t)}
</p>
)}
</SettingsCard>
{/* Password card. v1.4.19 A6: action-button placement contract —
on mobile (`<sm`) the action button stacks below the title +
description and renders full-width; on desktop (`>=sm`) it
@@ -1,282 +0,0 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Loader2, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { SettingsCard } from "@/components/settings/settings-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { formatDate } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
interface PasskeyInfo {
id: string;
name: string;
credentialDeviceType: string;
credentialBackedUp: boolean;
createdAt: string;
}
export function PasskeyListSection({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [deleteMsg, setDeleteMsg] = useState<string | null>(null);
const { data: passkeys } = useQuery({
queryKey: queryKeys.passkeys(),
queryFn: async () => {
return apiGet<PasskeyInfo[]>("/api/auth/passkeys");
},
enabled: isAuthenticated,
});
const deletePasskey = useMutation({
mutationFn: async (id: string) => {
await apiDelete(`/api/auth/passkeys/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
setDeleteMsg(null);
},
onError: (err: Error) => {
setDeleteMsg(err.message);
},
});
const DEVICE_TYPE_LABELS: Record<string, string> = {
singleDevice: t("settings.singleDevice"),
multiDevice: t("settings.multiDevice"),
};
if (!passkeys || passkeys.length === 0) {
return (
<div>
<h3 className="text-sm font-semibold">{t("settings.passkeys")}</h3>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.noPasskeys")}
</p>
</div>
);
}
return (
<div>
<h3 className="text-sm font-semibold">
{t("settings.registeredPasskeys")}
</h3>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.passkeysDescription")}
</p>
{/* Phase A5 / B-mobile HIGH: the passkey table previously
rendered with `min-w-[620px]` inside `overflow-x-auto`,
which on a 393px viewport hid the right-most column —
including the destructive delete action. Render the desktop
table only at `≥ md`, and at `< md` paint a card-list where
every passkey's name, device type, backup status, created
date, and delete action are all visible without scrolling. */}
{/* v1.4.33 — desktop table flips to card list at `lg` instead
of `md` so iPad portrait (768 px = exactly the `md`
inflection) lands on the card layout. The table needs
~620 px of column width to read; below that it scrolls
horizontally and the destructive delete column is the one
that disappears. */}
<div className="border-border mt-3 hidden overflow-x-auto rounded-lg border lg:block">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("settings.passkeyName")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.passkeyDevice")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.passkeyBackup")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.passkeyCreated")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("settings.passkeyActions")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{passkeys.map((pk, idx) => (
<tr key={pk.id} className={idx % 2 === 0 ? "bg-muted/20" : ""}>
<td className="px-3 py-2 font-medium">{pk.name}</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
pk.credentialDeviceType}
</td>
<td className="px-3 py-2 text-xs">
<Badge
variant={pk.credentialBackedUp ? "secondary" : "outline"}
className="text-xs"
>
{pk.credentialBackedUp
? t("settings.backedUp")
: t("common.no")}
</Badge>
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{formatDate(pk.createdAt)}
</td>
<td className="px-3 py-2 text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-destructive h-8 w-8"
disabled={deletePasskey.isPending}
aria-label={t("settings.deletePasskey")}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deletePasskey")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deletePasskeyDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deletePasskey.isPending}
aria-busy={deletePasskey.isPending || undefined}
onClick={() => deletePasskey.mutate(pk.id)}
>
{deletePasskey.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile + tablet card list — < lg only. Each passkey gets
its own card so the delete action stays visible and
tap-targetable (44 px) without the user having to
horizontally scroll through a wide table. v1.4.33 bumped
the breakpoint from `md` to `lg` so iPad portrait (768 px)
stays on the card layout instead of flipping between
layouts on rotation. */}
<ul
className="mt-3 space-y-2 lg:hidden"
data-testid="passkeys-mobile-list"
>
{passkeys.map((pk) => (
<SettingsCard as="li" key={pk.id}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{pk.name}</p>
{/* v1.4.27 MB7 / CF-75 — promote the device-type from
plain text to an outline Badge so the mobile card
list reads consistent with the desktop table's
"Single-device / Multi-device" column. Sits on the
same chip row as the backup status and date so all
metadata reads as a single horizontal stride. */}
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs">
<Badge variant="outline" className="text-xs">
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
pk.credentialDeviceType}
</Badge>
<Badge
variant={pk.credentialBackedUp ? "secondary" : "outline"}
className="text-xs"
>
{pk.credentialBackedUp
? t("settings.backedUp")
: t("common.no")}
</Badge>
<span className="text-muted-foreground">
{formatDate(pk.createdAt)}
</span>
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-destructive min-h-11 min-w-11"
disabled={deletePasskey.isPending}
aria-label={t("settings.deletePasskey")}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deletePasskey")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deletePasskeyDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deletePasskey.isPending}
aria-busy={deletePasskey.isPending || undefined}
onClick={() => deletePasskey.mutate(pk.id)}
>
{deletePasskey.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</SettingsCard>
))}
</ul>
{deleteMsg && (
<div
role="alert"
className="text-destructive mt-2 flex items-center gap-2 text-sm"
>
<AlertTriangle className="h-4 w-4" />
{deleteMsg}
</div>
)}
</div>
);
}
@@ -28,6 +28,7 @@ import {
Radio,
Settings2,
Share2,
ShieldCheck,
SlidersHorizontal,
Smile,
Sparkles,
@@ -43,6 +44,7 @@ import type { SettingsSectionSlug } from "./section-slugs";
const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
account: User,
security: ShieldCheck,
modules: Blocks,
integrations: Link2,
channels: Radio,
+4
View File
@@ -49,6 +49,10 @@
// its own top-level entry.
export const SETTINGS_SECTION_SLUGS = [
"account",
// v1.23 — `security` is the dedicated account-security home: second-factor
// setup (TOTP + security keys), recovery codes, and passkey management. Sits
// right after `account` so the two identity surfaces read as neighbours.
"security",
// v1.18.0 — `modules` ("Was du trackst") sits right after account: the
// single front door for enabling/disabling secondary tracking domains.
"modules",
@@ -0,0 +1,72 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { TotpCard } from "./totp-card";
import { SecurityKeysCard, type WebauthnKeyInfo } from "./security-keys-card";
import { PasskeyListSection } from "./passkey-list-section";
import { PasskeyUpgradeNudge } from "./passkey-upgrade-nudge";
interface MfaStatus {
totp: { enabled: boolean };
recoveryCodesRemaining: number;
webauthn: WebauthnKeyInfo[];
passkeyNudgeDismissed: boolean;
}
interface PasskeyInfo {
id: string;
}
export function SecuritySection() {
const { isAuthenticated } = useAuth();
const { data: status, isLoading } = useQuery({
queryKey: queryKeys.mfaStatus(),
queryFn: async () => apiGet<MfaStatus>("/api/auth/me/mfa"),
enabled: isAuthenticated,
});
// Passkey count drives whether the upgrade nudge shows. Shares the cached
// `passkeys()` key with the list below, so this is not an extra round-trip.
const { data: passkeys } = useQuery({
queryKey: queryKeys.passkeys(),
queryFn: async () => apiGet<PasskeyInfo[]>("/api/auth/passkeys"),
enabled: isAuthenticated,
});
const showNudge =
status != null &&
!status.passkeyNudgeDismissed &&
passkeys != null &&
passkeys.length === 0;
if (isLoading || !status) {
return (
<div className="space-y-4">
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
</div>
);
}
return (
<div className="space-y-6">
{showNudge && <PasskeyUpgradeNudge />}
<TotpCard
enabled={status.totp.enabled}
recoveryCodesRemaining={status.recoveryCodesRemaining}
/>
<SecurityKeysCard keys={status.webauthn} />
<PasskeyListSection isAuthenticated={isAuthenticated} />
</div>
);
}
@@ -0,0 +1,315 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Check,
Fingerprint,
KeyRound,
Loader2,
Pencil,
Trash2,
X,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { formatDate } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
ApiError,
apiDelete,
apiGet,
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { describePasskeyError } from "@/lib/passkey-errors";
interface PasskeyInfo {
id: string;
name: string;
credentialDeviceType: string;
credentialBackedUp: boolean;
createdAt: string;
lastUsedAt: string | null;
}
/**
* Primary-passkey management: list (name, device, backup, created, last used),
* add (registration ceremony), rename, and delete. The passwordless-primary
* passkey home — distinct from the second-factor security keys above.
*/
export function PasskeyListSection({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [msg, setMsg] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const { data: passkeys } = useQuery({
queryKey: queryKeys.passkeys(),
queryFn: async () => apiGet<PasskeyInfo[]>("/api/auth/passkeys"),
enabled: isAuthenticated,
});
const add = useMutation({
mutationFn: async () => {
const { startRegistration } = await import("@simplewebauthn/browser");
const { options, challengeId } = await apiPost<{
options: Parameters<typeof startRegistration>[0]["optionsJSON"];
challengeId: string;
}>("/api/auth/passkey/register-options");
const credential = await startRegistration({ optionsJSON: options });
await apiPost("/api/auth/passkey/register-verify", {
challengeId,
credential,
});
},
onSuccess: () => {
setMsg(null);
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
},
onError: (err) => {
if (err instanceof ApiError) {
setMsg(err.message || t("settings.passkeyRegistrationFailed"));
} else {
const { key, params } = describePasskeyError(err);
setMsg(t(key, params));
}
},
});
const rename = useMutation({
mutationFn: async ({ id, name }: { id: string; name: string }) => {
await apiPatch(`/api/auth/passkeys/${id}`, { name });
},
onSuccess: () => {
setEditingId(null);
setMsg(null);
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
},
onError: (err: Error) => setMsg(err.message),
});
const remove = useMutation({
mutationFn: async (id: string) => {
await apiDelete(`/api/auth/passkeys/${id}`);
},
onSuccess: () => {
setMsg(null);
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
},
onError: (err: Error) => setMsg(err.message),
});
const DEVICE_TYPE_LABELS: Record<string, string> = {
singleDevice: t("settings.singleDevice"),
multiDevice: t("settings.multiDevice"),
};
return (
<SettingsCard>
<SettingsCardHeader
icon={Fingerprint}
title={t("settings.passkeys")}
description={t("settings.passkeysDescription")}
/>
<div className="mt-4">
{!passkeys || passkeys.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("settings.noPasskeys")}
</p>
) : (
<ul className="space-y-2" data-testid="passkeys-list">
{passkeys.map((pk) => (
<SettingsCard as="li" key={pk.id}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
{editingId === pk.id ? (
<form
className="flex items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
rename.mutate({ id: pk.id, name: editName.trim() });
}}
>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
maxLength={64}
aria-label={t("settings.passkeyName")}
autoFocus
/>
<Button
type="submit"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
disabled={
rename.isPending || editName.trim().length === 0
}
aria-label={t("common.save")}
>
{rename.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Check className="h-4 w-4" />
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setEditingId(null)}
aria-label={t("common.cancel")}
>
<X className="h-4 w-4" />
</Button>
</form>
) : (
<>
<p className="truncate text-sm font-medium">
{pk.name}
</p>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs">
<Badge variant="outline" className="text-xs">
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
pk.credentialDeviceType}
</Badge>
<Badge
variant={
pk.credentialBackedUp ? "secondary" : "outline"
}
className="text-xs"
>
{pk.credentialBackedUp
? t("settings.backedUp")
: t("common.no")}
</Badge>
<span className="text-muted-foreground">
{pk.lastUsedAt
? t("settings.security.keys.lastUsed", {
date: formatDate(pk.lastUsedAt),
})
: t("settings.security.keys.neverUsed")}
</span>
</div>
</>
)}
</div>
{editingId !== pk.id && (
<div className="flex shrink-0 gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
onClick={() => {
setEditingId(pk.id);
setEditName(pk.name);
}}
aria-label={t("settings.security.keys.rename")}
>
<Pencil className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
disabled={remove.isPending}
aria-label={t("settings.deletePasskey")}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deletePasskey")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deletePasskeyDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={remove.isPending}
aria-busy={remove.isPending || undefined}
onClick={() => remove.mutate(pk.id)}
>
{remove.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</div>
</SettingsCard>
))}
</ul>
)}
<div className="mt-4">
<Button
type="button"
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={() => add.mutate()}
disabled={add.isPending}
>
{add.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="h-4 w-4" />
)}
{t("settings.addPasskey")}
</Button>
</div>
{msg && (
<div
role="alert"
className="text-destructive mt-3 flex items-center gap-2 text-sm"
>
<AlertTriangle className="h-4 w-4 shrink-0" />
{msg}
</div>
)}
</div>
</SettingsCard>
);
}
@@ -0,0 +1,53 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiPost } from "@/lib/api/api-fetch";
/**
* One-time, dismissible "add a passkey" prompt. Shown only when the account
* has no passkey registered and has not dismissed the nudge before. Dismissal
* is persisted on the user (`passkeyUpgradeNudgeDismissed`).
*/
export function PasskeyUpgradeNudge() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const dismiss = useMutation({
mutationFn: async () => {
await apiPost("/api/auth/me/passkey-nudge/dismiss");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
},
});
return (
<div className="border-primary/30 bg-primary/5 flex items-start gap-3 rounded-lg border p-4">
<Sparkles className="text-primary mt-0.5 h-5 w-5 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{t("settings.security.passkeyNudge.title")}
</p>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.security.passkeyNudge.body")}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => dismiss.mutate()}
disabled={dismiss.isPending}
aria-label={t("common.dismiss")}
>
<X className="h-4 w-4" />
</Button>
</div>
);
}
@@ -0,0 +1,96 @@
"use client";
import { useState } from "react";
import { Check, Copy, Download, ShieldAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
/**
* One-time display of a freshly generated recovery-code set.
*
* Recovery codes are shown exactly once (the server only stores their hashes),
* so this panel leads with an explicit "save these now" warning and offers
* copy + download so the user cannot lose them to a closed tab. The caller
* owns when the panel disappears (e.g. an explicit "I've saved them" action).
*/
export function RecoveryCodesPanel({ codes }: { codes: string[] }) {
const { t } = useTranslations();
const [copied, setCopied] = useState(false);
const asText = codes.join("\n");
async function handleCopy() {
try {
await navigator.clipboard.writeText(asText);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard can be blocked (insecure context / permissions); the codes
// stay visible and downloadable, so this is a non-fatal best-effort.
}
}
function handleDownload() {
const blob = new Blob([`${asText}\n`], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "healthlog-recovery-codes.txt";
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="border-border bg-muted/30 mt-4 rounded-lg border p-4">
<div className="text-destructive flex items-start gap-2">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0" />
<p className="text-sm font-medium">
{t("settings.security.recovery.saveNow")}
</p>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.security.recovery.saveNowHint")}
</p>
<ul
className="mt-3 grid grid-cols-2 gap-2 font-mono text-sm"
data-testid="recovery-codes"
>
{codes.map((code) => (
<li
key={code}
className="border-border bg-background rounded border px-2 py-1.5 text-center tracking-wider"
>
{code}
</li>
))}
</ul>
<div className="mt-3 flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-9"
onClick={handleCopy}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
{copied ? t("common.copied") : t("common.copy")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-9"
onClick={handleDownload}
>
<Download className="h-3.5 w-3.5" />
{t("settings.security.recovery.download")}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,304 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Check,
Loader2,
Pencil,
Trash2,
Usb,
X,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
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";
export interface WebauthnKeyInfo {
id: string;
name: string;
createdAt: string;
lastUsedAt: string | null;
}
function describeStepUp(
err: unknown,
fallback: string,
stepUpMsg: string,
): string {
if (err instanceof ApiError) {
const code = err.meta?.errorCode;
if (
err.status === 401 &&
typeof code === "string" &&
code.startsWith("auth.stepup")
) {
return stepUpMsg;
}
return err.message || fallback;
}
return fallback;
}
export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const add = useMutation({
mutationFn: async () => {
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");
const credential = await startRegistration({ optionsJSON: options });
await apiPost("/api/auth/me/mfa/webauthn/register/verify", {
challengeId,
credential,
});
},
onSuccess: () => {
setError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
},
onError: (err) => {
if (err instanceof ApiError) {
setError(err.message || t("settings.security.keys.addFailed"));
} else {
const { key, params } = describePasskeyError(err);
setError(t(key, params));
}
},
});
const rename = useMutation({
mutationFn: async ({ id, name }: { id: string; name: string }) => {
await apiPatch(`/api/auth/me/mfa/webauthn/${id}`, { name });
},
onSuccess: () => {
setEditingId(null);
setError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
},
onError: (err) =>
setError(
err instanceof ApiError
? err.message
: t("settings.security.keys.renameFailed"),
),
});
const remove = useMutation({
mutationFn: async (id: string) => {
await apiDelete(`/api/auth/me/mfa/webauthn/${id}`);
},
onSuccess: () => {
setError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
},
onError: (err) =>
setError(
describeStepUp(
err,
t("settings.security.keys.removeFailed"),
t("settings.security.stepUpRequired"),
),
),
});
return (
<SettingsCard>
<SettingsCardHeader
icon={Usb}
title={t("settings.security.keys.title")}
description={t("settings.security.keys.description")}
/>
<div className="mt-4">
{keys.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("settings.security.keys.empty")}
</p>
) : (
<ul className="space-y-2" data-testid="security-keys-list">
{keys.map((key) => (
<SettingsCard as="li" key={key.id}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
{editingId === key.id ? (
<form
className="flex items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
rename.mutate({ id: key.id, name: editName.trim() });
}}
>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
maxLength={64}
aria-label={t("settings.security.keys.nameLabel")}
autoFocus
/>
<Button
type="submit"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
disabled={
rename.isPending || editName.trim().length === 0
}
aria-label={t("common.save")}
>
{rename.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Check className="h-4 w-4" />
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setEditingId(null)}
aria-label={t("common.cancel")}
>
<X className="h-4 w-4" />
</Button>
</form>
) : (
<>
<p className="truncate text-sm font-medium">
{key.name}
</p>
<p className="text-muted-foreground mt-1 text-xs">
{key.lastUsedAt
? t("settings.security.keys.lastUsed", {
date: formatDate(key.lastUsedAt),
})
: t("settings.security.keys.neverUsed")}
{" · "}
{t("settings.security.keys.added", {
date: formatDate(key.createdAt),
})}
</p>
</>
)}
</div>
{editingId !== key.id && (
<div className="flex shrink-0 gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
onClick={() => {
setEditingId(key.id);
setEditName(key.name);
}}
aria-label={t("settings.security.keys.rename")}
>
<Pencil className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
disabled={remove.isPending}
aria-label={t("settings.security.keys.remove")}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.security.keys.remove")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.security.keys.removeConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={remove.isPending}
aria-busy={remove.isPending || undefined}
onClick={() => remove.mutate(key.id)}
>
{remove.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</div>
</SettingsCard>
))}
</ul>
)}
<div className="mt-4">
<Button
type="button"
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={() => add.mutate()}
disabled={add.isPending}
>
{add.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Usb className="h-4 w-4" />
)}
{t("settings.security.keys.add")}
</Button>
</div>
{error && (
<div
role="alert"
className="text-destructive mt-3 flex items-center gap-2 text-sm"
>
<AlertTriangle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
</div>
</SettingsCard>
);
}
@@ -0,0 +1,449 @@
"use client";
import { useId, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Check,
Copy,
KeyRound,
Loader2,
Smartphone,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
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";
interface SetupData {
otpauthUri: string;
totpSecret: string;
}
interface ConfirmResult {
enabled: boolean;
recoveryCodes: string[];
recoveryCodesRemaining: number;
}
/** Map a step-up 401 to a clear re-login message; otherwise the raw message. */
function describeError(
err: unknown,
fallback: string,
stepUpMsg: string,
): string {
if (err instanceof ApiError) {
const code = err.meta?.errorCode;
if (
err.status === 401 &&
typeof code === "string" &&
code.startsWith("auth.stepup")
) {
return stepUpMsg;
}
return err.message || fallback;
}
return fallback;
}
export function TotpCard({
enabled,
recoveryCodesRemaining,
}: {
enabled: boolean;
recoveryCodesRemaining: number;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const codeFieldId = useId();
const [setup, setSetup] = useState<SetupData | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [code, setCode] = useState("");
const [secretCopied, setSecretCopied] = useState(false);
const [freshCodes, setFreshCodes] = useState<string[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [disableCode, setDisableCode] = useState("");
const [disableMethod, setDisableMethod] = useState<"totp" | "recovery">(
"totp",
);
function resetWizard() {
setSetup(null);
setQrDataUrl(null);
setCode("");
setSecretCopied(false);
setError(null);
}
const beginSetup = useMutation({
mutationFn: async () => {
return apiPost<SetupData>("/api/auth/me/mfa/totp/setup");
},
onSuccess: async (data) => {
setError(null);
setSetup(data);
try {
const QRCode = (await import("qrcode")).default;
setQrDataUrl(await QRCode.toDataURL(data.otpauthUri, { margin: 1 }));
} catch {
// QR render is best-effort — the manual secret below is the fallback.
setQrDataUrl(null);
}
},
onError: (err) =>
setError(
describeError(
err,
t("settings.security.totp.setupFailed"),
t("settings.security.stepUpRequired"),
),
),
});
const confirm = useMutation({
mutationFn: async () => {
return apiPost<ConfirmResult>("/api/auth/me/mfa/totp/confirm", { code });
},
onSuccess: (data) => {
setError(null);
setFreshCodes(data.recoveryCodes);
resetWizard();
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
},
onError: (err) =>
setError(
describeError(
err,
t("settings.security.totp.invalidCode"),
t("settings.security.stepUpRequired"),
),
),
});
const disable = useMutation({
mutationFn: async () => {
await apiPost("/api/auth/me/mfa/disable", {
code: disableCode,
method: disableMethod,
});
},
onSuccess: () => {
setDisableCode("");
setFreshCodes(null);
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
},
onError: (err) =>
setError(
describeError(
err,
t("settings.security.totp.disableFailed"),
t("settings.security.stepUpRequired"),
),
),
});
const regenerate = useMutation({
mutationFn: async () => {
return apiPost<{ recoveryCodes: string[] }>(
"/api/auth/me/mfa/recovery-codes/regenerate",
);
},
onSuccess: (data) => {
setError(null);
setFreshCodes(data.recoveryCodes);
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
},
onError: (err) =>
setError(
describeError(
err,
t("settings.security.recovery.regenFailed"),
t("settings.security.stepUpRequired"),
),
),
});
async function copySecret() {
if (!setup) return;
try {
await navigator.clipboard.writeText(setup.totpSecret);
setSecretCopied(true);
setTimeout(() => setSecretCopied(false), 2000);
} catch {
/* clipboard blocked — the secret stays visible for manual entry */
}
}
return (
<SettingsCard>
<SettingsCardHeader
icon={Smartphone}
title={t("settings.security.totp.title")}
description={t("settings.security.totp.description")}
status={
enabled ? (
<Badge variant="secondary">{t("settings.security.active")}</Badge>
) : (
<Badge variant="outline">{t("settings.security.inactive")}</Badge>
)
}
/>
<div className="mt-4">
{/* ── Not enabled, no wizard: the enable CTA ── */}
{!enabled && !setup && !freshCodes && (
<Button
type="button"
className="min-h-11 sm:min-h-9"
onClick={() => beginSetup.mutate()}
disabled={beginSetup.isPending}
>
{beginSetup.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="h-4 w-4" />
)}
{t("settings.security.totp.setUp")}
</Button>
)}
{/* ── Enable wizard ── */}
{setup && (
<div className="space-y-4">
<ol className="text-muted-foreground list-decimal space-y-3 pl-5 text-sm">
<li>{t("settings.security.totp.step1")}</li>
<li>{t("settings.security.totp.step2")}</li>
</ol>
<div className="flex flex-col items-center gap-3 sm:flex-row sm:items-start">
{qrDataUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={qrDataUrl}
alt={t("settings.security.totp.qrAlt")}
width={176}
height={176}
className="border-border rounded-lg border bg-white p-2"
/>
) : (
<div className="border-border text-muted-foreground flex h-44 w-44 items-center justify-center rounded-lg border text-xs">
{t("settings.security.totp.qrUnavailable")}
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium">
{t("settings.security.totp.manualEntry")}
</p>
<div className="mt-1 flex items-center gap-2">
<code className="border-border bg-muted/40 min-w-0 flex-1 truncate rounded border px-2 py-1.5 font-mono text-xs">
{setup.totpSecret}
</code>
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8 shrink-0"
onClick={copySecret}
aria-label={t("common.copy")}
>
{secretCopied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
</div>
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
confirm.mutate();
}}
>
<Label htmlFor={codeFieldId}>
{t("settings.security.totp.enterCode")}
</Label>
<div className="flex gap-2">
<Input
id={codeFieldId}
value={code}
onChange={(e) =>
setCode(e.target.value.replace(/\D/g, "").slice(0, 6))
}
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
className="font-mono tracking-widest"
aria-invalid={!!error || undefined}
/>
<Button
type="submit"
className="min-h-11 sm:min-h-9"
disabled={confirm.isPending || code.length !== 6}
>
{confirm.isPending && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("settings.security.totp.verify")}
</Button>
</div>
<button
type="button"
onClick={resetWizard}
className="text-muted-foreground hover:text-foreground text-xs"
>
{t("common.cancel")}
</button>
</form>
</div>
)}
{/* ── Fresh recovery codes (post-confirm or post-regen) ── */}
{freshCodes && (
<div>
<RecoveryCodesPanel codes={freshCodes} />
<div className="mt-3 flex justify-end">
<Button
type="button"
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={() => setFreshCodes(null)}
>
{t("settings.security.recovery.savedIt")}
</Button>
</div>
</div>
)}
{/* ── Enabled, idle: recovery status + disable/regen actions ── */}
{enabled && !freshCodes && (
<div className="space-y-4">
<p className="text-muted-foreground text-sm">
{t(
recoveryCodesRemaining === 1
? "settings.security.recovery.remainingOne"
: "settings.security.recovery.remainingOther",
{ count: recoveryCodesRemaining },
)}
</p>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={() => regenerate.mutate()}
disabled={regenerate.isPending}
>
{regenerate.isPending && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("settings.security.recovery.regenerate")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
className="text-destructive min-h-11 sm:min-h-9"
>
{t("settings.security.totp.disable")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.security.totp.disableTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.security.totp.disableConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2">
<Label htmlFor="disable-code">
{t("settings.security.totp.currentCode")}
</Label>
<Input
id="disable-code"
value={disableCode}
onChange={(e) => setDisableCode(e.target.value)}
inputMode={disableMethod === "totp" ? "numeric" : "text"}
autoComplete="one-time-code"
placeholder={
disableMethod === "totp" ? "123456" : "XXXXX-XXXXX"
}
className="font-mono"
/>
<button
type="button"
className="text-muted-foreground hover:text-foreground text-xs"
onClick={() =>
setDisableMethod((m) =>
m === "totp" ? "recovery" : "totp",
)
}
>
{disableMethod === "totp"
? t("settings.security.useRecoveryCode")
: t("settings.security.useAuthenticator")}
</button>
</div>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={disable.isPending || disableCode.length < 6}
aria-busy={disable.isPending || undefined}
onClick={(e) => {
e.preventDefault();
disable.mutate();
}}
>
{disable.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.security.totp.disable")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
)}
{error && (
<div
role="alert"
className="text-destructive mt-3 flex items-center gap-2 text-sm"
>
<AlertTriangle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
</div>
</SettingsCard>
);
}
@@ -34,6 +34,7 @@ import {
Radio,
Settings2,
Share2,
ShieldCheck,
SlidersHorizontal,
Smile,
Sparkles,
@@ -99,6 +100,13 @@ interface SettingsSection {
*/
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
{ slug: "account", titleKey: "settings.sections.account.title", icon: User },
// v1.23 — the account-security home: second factors, recovery codes,
// passkey management. Sits directly under Account.
{
slug: "security",
titleKey: "settings.sections.security.title",
icon: ShieldCheck,
},
// v1.18.0 — the "Was du trackst" hub. Sits right below Account: the one
// place to enable/disable the secondary tracking domains.
{
+14 -1
View File
@@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
session: { findUnique: vi.fn() },
webauthnMfaCredential: { count: vi.fn() },
},
}));
@@ -91,8 +92,9 @@ describe("requireFreshMfa", () => {
);
});
it("rejects a user without an active second factor", async () => {
it("rejects a user with neither TOTP nor a security key", async () => {
mockSession({ ...MFA_USER, totpConfirmedAt: null });
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
errorCode: "auth.stepup.mfa_not_enrolled",
});
@@ -100,6 +102,17 @@ describe("requireFreshMfa", () => {
expect(prisma.session.findUnique).not.toHaveBeenCalled();
});
it("accepts a WebAuthn-only account with a fresh stamp", async () => {
mockSession({ ...MFA_USER, totpConfirmedAt: null });
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never);
vi.mocked(prisma.session.findUnique).mockResolvedValue({
mfaVerifiedAt: new Date(Date.now() - 60_000),
} as never);
const ctx = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
expect(ctx.user.id).toBe("user-1");
});
it("rejects when there is no cookie session — Bearer can never satisfy it", async () => {
// getSession is cookie-only; a Bearer header is irrelevant and never read.
headersGet.mockImplementation((n) =>
+19 -2
View File
@@ -539,8 +539,15 @@ export async function requireFreshMfa(
// The user must actually have a second factor active. A single-factor
// account cannot produce a fresh-MFA proof, so step-up-gated actions are
// unreachable for it by design (the management UI gates enrolment first).
// Either factor counts: a confirmed TOTP secret OR a registered WebAuthn
// security key — both stamp `Session.mfaVerifiedAt` on a completed login.
if (!sessionData.user.totpConfirmedAt) {
throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled");
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
where: { userId: sessionData.user.id },
});
if (webauthnKeyCount === 0) {
throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled");
}
}
// Read the freshness stamp off the live session row — `getSession`'s
@@ -579,7 +586,17 @@ export async function requireFreshMfaIfEnrolled(
maxAgeSeconds: number,
): Promise<AuthContext> {
const auth = await requireAuth();
if (auth.user.totpConfirmedAt) {
// Either factor enrols the account: a confirmed TOTP secret OR a registered
// WebAuthn security key. A webauthn-only user must clear step-up too, so the
// destructive-action boundary tracks `requireFreshMfa`'s either-factor rule.
let enrolled = Boolean(auth.user.totpConfirmedAt);
if (!enrolled) {
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
where: { userId: auth.user.id },
});
enrolled = webauthnKeyCount > 0;
}
if (enrolled) {
await requireFreshMfa(maxAgeSeconds);
}
return auth;
+290
View File
@@ -0,0 +1,290 @@
/**
* WebAuthn as a **second factor** — distinct from the passwordless-primary
* passkey path in `src/lib/auth/passkey.ts`.
*
* Differences from the primary path (see `.planning` design §3.2):
* - Credentials live in `WebauthnMfaCredential`, NOT `Passkey`. A `Passkey`
* row is itself a login credential; an MFA credential is meaningless without
* the password, so conflating the two would silently turn a security-key
* registration into a passwordless login credential.
* - Registration uses `residentKey: "discouraged"` (non-resident → does not
* consume a discoverable-credential slot, so an account can register many
* keys) and `userVerification: "preferred"` (the password already supplied
* one factor; a roaming key need not always carry a PIN/biometric).
* - The login ceremony scopes `allowCredentials` to the **already
* password-identified** user's MFA keys — never an empty allow-list, because
* a non-resident credential is not discoverable.
*
* The transient challenge rides the shared `AuthChallenge` store under
* dedicated `mfa_registration` / `mfa_authentication` types so it never
* collides with the primary-passkey challenges.
*/
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
type AuthenticationResponseJSON,
type RegistrationResponseJSON,
type VerifiedRegistrationResponse,
type VerifiedAuthenticationResponse,
} from "@simplewebauthn/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import {
RP_NAME as rpName,
getRpId,
getExpectedOrigin,
} from "@/lib/auth/webauthn-rp";
// Boundary narrowing mirrors `passkey.ts`: a malformed body fails fast with a
// structured error here, while SimpleWebAuthn still owns the cryptographic
// validation downstream.
const registrationResponseSchema = z
.object({
id: z.string().min(1),
rawId: z.string().min(1),
response: z
.object({
clientDataJSON: z.string().min(1),
attestationObject: z.string().min(1),
transports: z.array(z.string()).optional(),
publicKeyAlgorithm: z.number().optional(),
publicKey: z.string().optional(),
authenticatorData: z.string().optional(),
})
.loose(),
authenticatorAttachment: z.string().optional(),
clientExtensionResults: z.unknown().optional(),
type: z.literal("public-key"),
})
.loose();
const authenticationResponseSchema = z
.object({
id: z.string().min(1),
rawId: z.string().min(1),
response: z
.object({
clientDataJSON: z.string().min(1),
authenticatorData: z.string().min(1),
signature: z.string().min(1),
userHandle: z.string().optional(),
})
.loose(),
authenticatorAttachment: z.string().optional(),
clientExtensionResults: z.unknown().optional(),
type: z.literal("public-key"),
})
.loose();
type Transport =
| "ble"
| "cable"
| "hybrid"
| "internal"
| "nfc"
| "smart-card"
| "usb";
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
async function cleanupExpiredChallenges() {
await prisma.authChallenge.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
}
// ── Registration (settings, cookie-authenticated) ────────────────────
export async function createMfaRegistrationOptions(
userId: string,
username: string,
) {
await cleanupExpiredChallenges();
const existing = await prisma.webauthnMfaCredential.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
const options = await generateRegistrationOptions({
rpName,
rpID: getRpId(),
userName: username,
attestationType: "none",
excludeCredentials: existing.map((c) => ({
id: c.credentialId,
transports: c.transports as Transport[],
})),
authenticatorSelection: {
// Non-resident: the password already identifies the account, so the
// credential need not be discoverable — and a non-resident key does not
// burn a discoverable-credential slot, allowing many keys per account.
residentKey: "discouraged",
// A roaming security key supplies the possession factor; UV is a bonus,
// not a requirement, because the password already covered a factor.
userVerification: "preferred",
authenticatorAttachment: "cross-platform",
},
});
const challenge = await prisma.authChallenge.create({
data: {
userId,
challenge: options.challenge,
type: "mfa_registration",
expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS),
},
});
return { options, challengeId: challenge.id };
}
export async function verifyMfaRegistration(
challengeId: string,
userId: string,
response: unknown,
): Promise<VerifiedRegistrationResponse> {
const challenge = await prisma.authChallenge.findUnique({
where: { id: challengeId },
});
// The challenge must be live, of the registration type, and bound to the
// calling user — a challenge minted for another account can never be reused.
if (
!challenge ||
challenge.expiresAt < new Date() ||
challenge.type !== "mfa_registration" ||
challenge.userId !== userId
) {
throw new Error("Challenge expired or not found");
}
try {
const parsed = registrationResponseSchema.safeParse(response);
if (!parsed.success) {
throw new Error("Registration response shape invalid");
}
return await verifyRegistrationResponse({
response: parsed.data as RegistrationResponseJSON,
expectedChallenge: challenge.challenge,
expectedOrigin: getExpectedOrigin(),
expectedRPID: getRpId(),
});
} finally {
await prisma.authChallenge
.delete({ where: { id: challengeId } })
.catch(() => {});
}
}
// ── Authentication (mid-login second factor) ─────────────────────────
/**
* Generate an assertion challenge scoped to a single user's MFA credentials.
* The caller has already password-identified the account (via the MFA ticket),
* so `allowCredentials` is always populated — a non-resident credential is not
* discoverable from an empty allow-list. Returns null when the user has no
* security keys registered (the caller should fall back to another factor).
*/
export async function createMfaAuthenticationOptions(userId: string) {
await cleanupExpiredChallenges();
const credentials = await prisma.webauthnMfaCredential.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
if (credentials.length === 0) return null;
const options = await generateAuthenticationOptions({
rpID: getRpId(),
userVerification: "preferred",
allowCredentials: credentials.map((c) => ({
id: c.credentialId,
transports: c.transports as Transport[],
})),
});
const challenge = await prisma.authChallenge.create({
data: {
userId,
challenge: options.challenge,
type: "mfa_authentication",
expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS),
},
});
return { options, challengeId: challenge.id };
}
/**
* Verify a mid-login security-key assertion against the user's MFA credentials.
* Returns true on a verified assertion (counter + last-used are stamped); the
* caller then claims the MFA ticket and finishes the login.
*/
export async function verifyMfaAuthentication(
challengeId: string,
userId: string,
response: unknown,
): Promise<boolean> {
const challenge = await prisma.authChallenge.findUnique({
where: { id: challengeId },
});
if (
!challenge ||
challenge.expiresAt < new Date() ||
challenge.type !== "mfa_authentication" ||
challenge.userId !== userId
) {
throw new Error("Challenge expired or not found");
}
const parsed = authenticationResponseSchema.safeParse(response);
if (!parsed.success) {
throw new Error("Malformed security-key authentication response");
}
const typedResponse = parsed.data as unknown as AuthenticationResponseJSON;
try {
// The presented credential MUST belong to the password-identified user —
// look it up scoped to `userId` so an assertion against another account's
// key can never satisfy this account's second factor.
const credential = await prisma.webauthnMfaCredential.findFirst({
where: { credentialId: typedResponse.id, userId },
});
if (!credential) return false;
const verification: VerifiedAuthenticationResponse =
await verifyAuthenticationResponse({
response: typedResponse,
expectedChallenge: challenge.challenge,
expectedOrigin: getExpectedOrigin(),
expectedRPID: getRpId(),
credential: {
id: credential.credentialId,
publicKey: credential.credentialPublicKey,
counter: Number(credential.counter),
transports: credential.transports as Transport[],
},
});
if (verification.verified) {
await prisma.webauthnMfaCredential.update({
where: { id: credential.id },
data: {
counter: BigInt(verification.authenticationInfo.newCounter),
lastUsedAt: new Date(),
},
});
}
return verification.verified;
} finally {
await prisma.authChallenge
.delete({ where: { id: challengeId } })
.catch(() => {});
}
}
+14 -35
View File
@@ -10,6 +10,11 @@ import {
} from "@simplewebauthn/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import {
RP_NAME as rpName,
getRpId,
getExpectedOrigin,
} from "@/lib/auth/webauthn-rp";
// v1.4.43 W13 L-3 — explicit Zod narrowing in front of
// `verifyAuthentication`. Replaces the raw
@@ -76,7 +81,9 @@ type Transport =
| "smart-card"
| "usb";
const rpName = "HealthLog";
// The relying-party config (`rpName` / `getRpId` / `getExpectedOrigin`) is
// shared with the second-factor security-key path via `@/lib/auth/webauthn-rp`
// so both ceremonies bind credentials to the identical RP.
async function cleanupExpiredChallenges() {
await prisma.authChallenge.deleteMany({
@@ -84,38 +91,6 @@ async function cleanupExpiredChallenges() {
});
}
function getConfiguredOrigins(): string[] {
const candidates = [
process.env.APP_URL,
process.env.NEXT_PUBLIC_APP_URL,
"http://localhost:3000",
]
.map((value) => value?.trim())
.filter((value): value is string => Boolean(value));
const validOrigins = candidates
.map((value) => {
try {
return new URL(value).origin;
} catch {
return null;
}
})
.filter((value): value is string => Boolean(value));
return Array.from(new Set(validOrigins));
}
function getRpId(): string {
const origins = getConfiguredOrigins();
return new URL(origins[0]).hostname;
}
function getExpectedOrigin(): string | string[] {
const origins = getConfiguredOrigins();
return origins.length === 1 ? origins[0] : origins;
}
// ── Registration ─────────────────────────────────────────
export async function createRegistrationOptions(
@@ -294,10 +269,14 @@ export async function verifyAuthentication(
});
if (verification.verified) {
// Update counter
// Update counter + stamp last-used so the management UI can surface
// when each passkey was last exercised (v1.23 passkey QoL).
await prisma.passkey.update({
where: { id: passkey.id },
data: { counter: BigInt(verification.authenticationInfo.newCounter) },
data: {
counter: BigInt(verification.authenticationInfo.newCounter),
lastUsedAt: new Date(),
},
});
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Shared WebAuthn relying-party configuration.
*
* Both the passwordless-primary passkey path (`src/lib/auth/passkey.ts`) and
* the second-factor security-key path (`src/lib/auth/mfa/webauthn.ts`) resolve
* the same relying-party id / origin from the deployment's configured app URL,
* so the two ceremonies always agree on the RP they bind credentials to.
*
* The candidates mirror the rest of the app: `APP_URL` first, then the public
* build-time URL, then a localhost fallback for dev. A multi-origin deployment
* (rare for a single-tenant self-host, but supported) passes the whole set to
* SimpleWebAuthn's `expectedOrigin`.
*/
/** Human-readable relying-party name shown by the authenticator UI. */
export const RP_NAME = "HealthLog";
export function getConfiguredOrigins(): string[] {
const candidates = [
process.env.APP_URL,
process.env.NEXT_PUBLIC_APP_URL,
"http://localhost:3000",
]
.map((value) => value?.trim())
.filter((value): value is string => Boolean(value));
const validOrigins = candidates
.map((value) => {
try {
return new URL(value).origin;
} catch {
return null;
}
})
.filter((value): value is string => Boolean(value));
return Array.from(new Set(validOrigins));
}
export function getRpId(): string {
const origins = getConfiguredOrigins();
return new URL(origins[0]).hostname;
}
export function getExpectedOrigin(): string | string[] {
const origins = getConfiguredOrigins();
return origins.length === 1 ? origins[0] : origins;
}
+209 -2
View File
@@ -12,6 +12,10 @@ import {
mfaVerifySchema,
totpConfirmSchema,
mfaDisableSchema,
mfaWebauthnRegisterVerifySchema,
mfaWebauthnRenameSchema,
mfaWebauthnLoginOptionsSchema,
mfaWebauthnLoginVerifySchema,
} from "@/lib/validations/mfa";
import { dataEnvelope, stdResponses, errorEnvelope } from "./shared";
@@ -92,9 +96,9 @@ const mfaRequiredEnvelope = z
"Opaque, single-use, ~5-minute ticket to present to /api/auth/mfa/verify.",
),
methods: z
.array(z.enum(["totp", "recovery"]))
.array(z.enum(["totp", "recovery", "webauthn"]))
.describe(
"Second factors the account can complete the challenge with.",
"Second factors the account can complete the challenge with. `webauthn` is completed via /api/auth/mfa/webauthn/verify; the rest via /api/auth/mfa/verify.",
),
}),
})
@@ -184,6 +188,39 @@ const securityActivityResponse = z
})
.meta({ id: "SecurityActivityResponse" });
// ── v1.23 WebAuthn second-factor + status shapes ─────────────────────
const webauthnCredentialInfo = z
.object({
id: z.string(),
name: z.string(),
createdAt: z.iso.datetime({ offset: true }),
lastUsedAt: z.iso.datetime({ offset: true }).nullable(),
})
.meta({ id: "WebauthnMfaCredentialInfo" });
const mfaStatusResponse = z
.object({
totp: z.object({ enabled: z.boolean() }),
recoveryCodesRemaining: z.number().int(),
webauthn: z.array(webauthnCredentialInfo),
passkeyNudgeDismissed: z.boolean(),
})
.meta({ id: "MfaStatusResponse" });
const webauthnOptionsResponse = z
.object({
options: z
.record(z.string(), z.unknown())
.describe("SimpleWebAuthn options to pass to the browser ceremony."),
challengeId: z.string().describe("Server-issued challenge id."),
})
.meta({ id: "WebauthnOptionsResponse" });
const successFlagResponse = z
.object({ success: z.boolean() })
.meta({ id: "AuthSuccessFlagResponse" });
export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/auth/login": {
post: {
@@ -327,6 +364,176 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/auth/me/mfa": {
get: {
tags: ["Auth"],
summary: "Second-factor status (cookie session only)",
description:
"Whether TOTP is active, how many recovery codes remain, and the registered WebAuthn security keys. Metadata only — no secret, code, or public key. Cookie-only.",
responses: {
"200": {
description: "Second-factor status.",
content: {
"application/json": {
schema: dataEnvelope(mfaStatusResponse, "MfaStatusEnvelope"),
},
},
},
...stdResponses,
},
},
},
"/api/auth/me/mfa/webauthn/register/options": {
post: {
tags: ["Auth"],
summary: "Begin registering a security key as a second factor",
description:
"Returns SimpleWebAuthn creation options + a challenge id. Cookie-only — a Bearer token cannot enrol MFA.",
responses: {
"200": {
description: "Registration options issued.",
content: {
"application/json": {
schema: dataEnvelope(
webauthnOptionsResponse,
"MfaWebauthnRegisterOptionsEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/auth/me/mfa/webauthn/register/verify": {
post: {
tags: ["Auth"],
summary: "Finish registering a security key as a second factor",
description:
"Verifies the attestation against the user-bound challenge and stores the credential in the second-factor store (separate from primary passkeys). Cookie-only.",
requestBody: {
required: true,
content: {
"application/json": { schema: mfaWebauthnRegisterVerifySchema },
},
},
responses: {
"200": {
description: "Security key registered.",
content: {
"application/json": {
schema: dataEnvelope(
webauthnCredentialInfo,
"MfaWebauthnRegisterVerifyEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/auth/me/mfa/webauthn/{id}": {
patch: {
tags: ["Auth"],
summary: "Rename a registered security key",
requestBody: {
required: true,
content: { "application/json": { schema: mfaWebauthnRenameSchema } },
},
responses: {
"200": {
description: "Security key renamed.",
content: {
"application/json": {
schema: dataEnvelope(
webauthnCredentialInfo,
"MfaWebauthnRenameEnvelope",
),
},
},
},
...stdResponses,
},
},
delete: {
tags: ["Auth"],
summary: "Remove a registered security key (step-up gated)",
description:
"Requires a fresh second-factor step-up (cookie session). Bearer can never satisfy the gate.",
responses: {
"200": {
description: "Security key removed.",
content: {
"application/json": {
schema: dataEnvelope(
successFlagResponse,
"MfaWebauthnRemoveEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/auth/mfa/webauthn/verify/options": {
post: {
tags: ["Auth"],
summary: "Begin a mid-login security-key assertion",
description:
"Presents the login `mfaTicket` and returns assertion options scoped to the password-identified user's registered security keys. Anonymous surface; rate-limited.",
requestBody: {
required: true,
content: {
"application/json": { schema: mfaWebauthnLoginOptionsSchema },
},
},
responses: {
"200": {
description: "Assertion options issued.",
content: {
"application/json": {
schema: dataEnvelope(
webauthnOptionsResponse,
"MfaWebauthnLoginOptionsEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/auth/mfa/webauthn/verify": {
post: {
tags: ["Auth"],
summary: "Complete a security-key second-factor login challenge",
description:
"Presents the login `mfaTicket` plus the assertion. On success returns the SAME token bundle / session the password path issues, with the session marked second-factor-verified. The ticket is single-use; failures are throttled and the ticket is burned at the attempt cap.",
requestBody: {
required: true,
content: {
"application/json": { schema: mfaWebauthnLoginVerifySchema },
},
},
responses: {
"200": {
description:
"Second factor verified — session + optional bearer issued.",
content: {
"application/json": {
schema: dataEnvelope(
accessRefreshBundle,
"MfaWebauthnVerifyResponse",
),
},
},
},
...stdResponses,
},
},
},
"/api/auth/passkey/login-verify": {
post: {
tags: ["Auth"],
+8
View File
@@ -48,6 +48,14 @@ export const authKeys = {
*/
securityActivity: () => ["auth", "security-activity"] as const,
/**
* v1.23 — the security settings hub reads second-factor status (TOTP
* enabled, recovery codes remaining, registered security keys, passkey-nudge
* dismissal) from `GET /api/auth/me/mfa`. Mutations that change a factor
* invalidate this key so the hub reflects the new state immediately.
*/
mfaStatus: () => ["auth", "mfa", "status"] as const,
userAiProvider: () => ["user", "ai-provider"] as const,
userProfile: () => ["user", "profile"] as const,
/**
+63
View File
@@ -48,3 +48,66 @@ export const mfaVerifySchema = z
description:
"Complete a second-factor login challenge and receive the same token bundle the password path issues.",
});
// ── WebAuthn security key (second factor) ────────────────────────────
/**
* A SimpleWebAuthn-style ceremony response. Kept permissive at the wire
* boundary — the cryptographic validation lives inside
* `@simplewebauthn/server`; this just pins the envelope shape for OpenAPI and
* the runtime `safeParse`.
*/
const webauthnCredentialSchema = z
.object({
id: z.string(),
rawId: z.string(),
type: z.literal("public-key"),
response: z.record(z.string(), z.unknown()),
authenticatorAttachment: z.enum(["platform", "cross-platform"]).optional(),
clientExtensionResults: z.record(z.string(), z.unknown()).optional(),
})
.describe("SimpleWebAuthn ceremony response payload.");
/** A human label for a registered security key (settings + rename). */
export const webauthnKeyNameSchema = z.string().trim().min(1).max(64);
/** Register a security key as a second factor (cookie session, settings). */
export const mfaWebauthnRegisterVerifySchema = z
.object({
challengeId: z.string().min(1),
credential: webauthnCredentialSchema,
name: webauthnKeyNameSchema.optional(),
})
.meta({
id: "MfaWebauthnRegisterVerifyRequest",
description:
"Finish registering a WebAuthn security key as a second factor.",
});
/** Rename a registered security key. */
export const mfaWebauthnRenameSchema = z
.object({ name: webauthnKeyNameSchema })
.meta({ id: "MfaWebauthnRenameRequest" });
/** Begin a mid-login security-key assertion: present the login MFA ticket. */
export const mfaWebauthnLoginOptionsSchema = z
.object({
mfaTicket: z
.string()
.min(1)
.describe("The opaque single-use ticket from the login response."),
})
.meta({ id: "MfaWebauthnLoginOptionsRequest" });
/** Complete a mid-login security-key assertion. */
export const mfaWebauthnLoginVerifySchema = z
.object({
mfaTicket: z.string().min(1),
challengeId: z.string().min(1),
credential: webauthnCredentialSchema,
})
.meta({
id: "MfaWebauthnLoginVerifyRequest",
description:
"Complete a second-factor login challenge with a WebAuthn security key and receive the same token bundle the password path issues.",
});