diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 648c082e6..4f1eaa3dd 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/messages/de.json b/messages/de.json index 8f7d31981..a60a3ee1a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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." } } }, diff --git a/messages/en.json b/messages/en.json index 5da217315..c2a12704e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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." } } }, diff --git a/messages/es.json b/messages/es.json index 4548ca6b3..0f7894bf1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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." } } }, diff --git a/messages/fr.json b/messages/fr.json index fe6010b7f..a08fa0fad 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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." } } }, diff --git a/messages/it.json b/messages/it.json index 8b3867b5c..664cb9f13 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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." } } }, diff --git a/messages/pl.json b/messages/pl.json index 0232877e1..71226cfc2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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." } } }, diff --git a/src/__tests__/api/auth/login/mfa-branch.test.ts b/src/__tests__/api/auth/login/mfa-branch.test.ts index e47fc51fd..6e65e27eb 100644 --- a/src/__tests__/api/auth/login/mfa-branch.test.ts +++ b/src/__tests__/api/auth/login/mfa-branch.test.ts @@ -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, diff --git a/src/app/api/auth/login/__tests__/native-token.test.ts b/src/app/api/auth/login/__tests__/native-token.test.ts index 6661fe644..24abf1edf 100644 --- a/src/app/api/auth/login/__tests__/native-token.test.ts +++ b/src/app/api/auth/login/__tests__/native-token.test.ts @@ -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 = {}): 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({ diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index d271cfd31..359d81777 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -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 }, diff --git a/src/app/api/auth/me/mfa/route.ts b/src/app/api/auth/me/mfa/route.ts new file mode 100644 index 000000000..2498f5e62 --- /dev/null +++ b/src/app/api/auth/me/mfa/route.ts @@ -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), + }); +}); diff --git a/src/app/api/auth/me/mfa/webauthn/[id]/route.ts b/src/app/api/auth/me/mfa/webauthn/[id]/route.ts new file mode 100644 index 000000000..8c83c7981 --- /dev/null +++ b/src/app/api/auth/me/mfa/webauthn/[id]/route.ts @@ -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 }); + }, +); diff --git a/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts b/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts new file mode 100644 index 000000000..0b2ad01f6 --- /dev/null +++ b/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts @@ -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): 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); + }); +}); diff --git a/src/app/api/auth/me/mfa/webauthn/register/options/route.ts b/src/app/api/auth/me/mfa/webauthn/register/options/route.ts new file mode 100644 index 000000000..350cf7f82 --- /dev/null +++ b/src/app/api/auth/me/mfa/webauthn/register/options/route.ts @@ -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 }); +}); diff --git a/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts new file mode 100644 index 000000000..5c4cad35e --- /dev/null +++ b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts @@ -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 | 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); +}); diff --git a/src/app/api/auth/me/passkey-nudge/dismiss/route.ts b/src/app/api/auth/me/passkey-nudge/dismiss/route.ts new file mode 100644 index 000000000..d21d0d3c6 --- /dev/null +++ b/src/app/api/auth/me/passkey-nudge/dismiss/route.ts @@ -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 }); +}); diff --git a/src/app/api/auth/mfa/webauthn/verify/__tests__/route.test.ts b/src/app/api/auth/mfa/webauthn/verify/__tests__/route.test.ts new file mode 100644 index 000000000..e53875cb5 --- /dev/null +++ b/src/app/api/auth/mfa/webauthn/verify/__tests__/route.test.ts @@ -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): 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); + }); +}); diff --git a/src/app/api/auth/mfa/webauthn/verify/options/route.ts b/src/app/api/auth/mfa/webauthn/verify/options/route.ts new file mode 100644 index 000000000..4f7a569d8 --- /dev/null +++ b/src/app/api/auth/mfa/webauthn/verify/options/route.ts @@ -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, + }); +}); diff --git a/src/app/api/auth/mfa/webauthn/verify/route.ts b/src/app/api/auth/mfa/webauthn/verify/route.ts new file mode 100644 index 000000000..875105c6b --- /dev/null +++ b/src/app/api/auth/mfa/webauthn/verify/route.ts @@ -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, + }); +}); diff --git a/src/app/api/auth/passkeys/[id]/route.ts b/src/app/api/auth/passkeys/[id]/route.ts index 5b0417864..4c9aadf2f 100644 --- a/src/app/api/auth/passkeys/[id]/route.ts +++ b/src/app/api/auth/passkeys/[id]/route.ts @@ -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 ( diff --git a/src/app/api/auth/passkeys/route.ts b/src/app/api/auth/passkeys/route.ts index 6c2a7d497..dfebfed0c 100644 --- a/src/app/api/auth/passkeys/route.ts +++ b/src/app/api/auth/passkeys/route.ts @@ -16,6 +16,7 @@ export const GET = apiHandler(async () => { credentialDeviceType: true, credentialBackedUp: true, createdAt: true, + lastUsedAt: true, }, orderBy: { createdAt: "desc" }, }); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index af70c3348..f0ddea12a 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -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(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[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 (
{/* 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() {
-
- {/* Phase A5 / B-mobile: bumped from default size (h-9, 36px) + {mfaChallenge ? ( +
+ { + await queryClient.invalidateQueries({ + queryKey: queryKeys.auth(), + }); + navigateAfterLogin(); + }} + onCancel={() => { + setMfaChallenge(null); + setError(null); + }} + /> +
+ ) : ( +
+ {/* 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. */} - - -
- - - {t("common.or")} - - -
- - {mode === "passkey" ? ( - ) : ( -
-
- - 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")} - /> -
-
- - setPassword(e.target.value)} - required - autoComplete="current-password" - enterKeyHint="go" - aria-required="true" - aria-invalid={!!error || undefined} - aria-describedby={errorDescriptor} - placeholder="********" - /> -
+ +
+ + + {t("common.or")} + + +
+ + {mode === "passkey" ? ( - -
- )} + ) : ( +
+
+ + 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")} + /> +
+
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + enterKeyHint="go" + aria-required="true" + aria-invalid={!!error || undefined} + aria-describedby={errorDescriptor} + placeholder="********" + /> +
+ + +
+ )} - {error && ( - - )} - - {registrationEnabled === true && ( -

- {t("auth.noAccount")}{" "} - - {t("auth.register")} - -

- )} -
+ {error} +
+ )} + + {registrationEnabled === true && ( +

+ {t("auth.noAccount")}{" "} + + {t("auth.register")} + +

+ )} + + )} {/* v1.4.26 — discoverable privacy link below the login card. App Store reviewers and first-time visitors need a one-click diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 8e7d77599..be04898a5 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -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, diff --git a/src/components/auth/__tests__/mfa-login-step.test.tsx b/src/components/auth/__tests__/mfa-login-step.test.tsx new file mode 100644 index 000000000..27aa5c923 --- /dev/null +++ b/src/components/auth/__tests__/mfa-login-step.test.tsx @@ -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( + {}} + 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"); + }); +}); diff --git a/src/components/auth/mfa-login-step.tsx b/src/components/auth/mfa-login-step.tsx new file mode 100644 index 000000000..86bc86308 --- /dev/null +++ b/src/components/auth/mfa-login-step.tsx @@ -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(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[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 ( +
+
+
+ +
+

{t("auth.mfa.title")}

+

+ {useRecovery + ? t("auth.mfa.recoveryHint") + : hasTotp + ? t("auth.mfa.totpHint") + : t("auth.mfa.securityKeyHint")} +

+
+ + {(hasTotp || useRecovery) && ( +
+
+ + + 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} + /> +
+ +
+ )} + + {hasWebauthn && ( + + )} + + {hasTotp && ( + + )} + + {error && ( + + )} + + +
+ ); +} diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index ebdc1945e..012b2e7e3 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -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", diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index 7ba648646..16d30e392 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -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(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. */} - {/* Passkeys card */} - - -
- -
-
- -
- {passkeyMsg && ( -

- {statusText(passkeyMsg, t)} -

- )} -
- {/* Password card. v1.4.19 A6: action-button placement contract — on mobile (`=sm`) it diff --git a/src/components/settings/account-section/passkey-list-section.tsx b/src/components/settings/account-section/passkey-list-section.tsx deleted file mode 100644 index cc9e4ebf3..000000000 --- a/src/components/settings/account-section/passkey-list-section.tsx +++ /dev/null @@ -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(null); - - const { data: passkeys } = useQuery({ - queryKey: queryKeys.passkeys(), - queryFn: async () => { - return apiGet("/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 = { - singleDevice: t("settings.singleDevice"), - multiDevice: t("settings.multiDevice"), - }; - - if (!passkeys || passkeys.length === 0) { - return ( -
-

{t("settings.passkeys")}

-

- {t("settings.noPasskeys")} -

-
- ); - } - - return ( -
-

- {t("settings.registeredPasskeys")} -

-

- {t("settings.passkeysDescription")} -

- {/* 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. */} -
- - - - - - - - - - - - {passkeys.map((pk, idx) => ( - - - - - - - - ))} - -
- {t("settings.passkeyName")} - - {t("settings.passkeyDevice")} - - {t("settings.passkeyBackup")} - - {t("settings.passkeyCreated")} - - {t("settings.passkeyActions")} -
{pk.name} - {DEVICE_TYPE_LABELS[pk.credentialDeviceType] ?? - pk.credentialDeviceType} - - - {pk.credentialBackedUp - ? t("settings.backedUp") - : t("common.no")} - - - {formatDate(pk.createdAt)} - - - - - - - - - {t("settings.deletePasskey")} - - - {t("settings.deletePasskeyDescription")} - - - - - {t("common.cancel")} - - deletePasskey.mutate(pk.id)} - > - {deletePasskey.isPending && ( - - )} - {t("common.delete")} - - - - -
-
- - {/* 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. */} -
    - {passkeys.map((pk) => ( - -
    -
    -

    {pk.name}

    - {/* 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. */} -
    - - {DEVICE_TYPE_LABELS[pk.credentialDeviceType] ?? - pk.credentialDeviceType} - - - {pk.credentialBackedUp - ? t("settings.backedUp") - : t("common.no")} - - - {formatDate(pk.createdAt)} - -
    -
    - - - - - - - - {t("settings.deletePasskey")} - - - {t("settings.deletePasskeyDescription")} - - - - {t("common.cancel")} - deletePasskey.mutate(pk.id)} - > - {deletePasskey.isPending && ( - - )} - {t("common.delete")} - - - - -
    -
    - ))} -
- {deleteMsg && ( -
- - {deleteMsg} -
- )} -
- ); -} diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx index 577f0c2dc..09f3ec64a 100644 --- a/src/components/settings/section-placeholder.tsx +++ b/src/components/settings/section-placeholder.tsx @@ -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 = { account: User, + security: ShieldCheck, modules: Blocks, integrations: Link2, channels: Radio, diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index 48419670d..002d44801 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -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", diff --git a/src/components/settings/security-section/index.tsx b/src/components/settings/security-section/index.tsx new file mode 100644 index 000000000..f2e9a5ac5 --- /dev/null +++ b/src/components/settings/security-section/index.tsx @@ -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("/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("/api/auth/passkeys"), + enabled: isAuthenticated, + }); + + const showNudge = + status != null && + !status.passkeyNudgeDismissed && + passkeys != null && + passkeys.length === 0; + + if (isLoading || !status) { + return ( +
+ + + +
+ ); + } + + return ( +
+ {showNudge && } + + + + + + +
+ ); +} diff --git a/src/components/settings/security-section/passkey-list-section.tsx b/src/components/settings/security-section/passkey-list-section.tsx new file mode 100644 index 000000000..03e1e6fc4 --- /dev/null +++ b/src/components/settings/security-section/passkey-list-section.tsx @@ -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(null); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(""); + + const { data: passkeys } = useQuery({ + queryKey: queryKeys.passkeys(), + queryFn: async () => apiGet("/api/auth/passkeys"), + enabled: isAuthenticated, + }); + + const add = useMutation({ + mutationFn: async () => { + const { startRegistration } = await import("@simplewebauthn/browser"); + const { options, challengeId } = await apiPost<{ + options: Parameters[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 = { + singleDevice: t("settings.singleDevice"), + multiDevice: t("settings.multiDevice"), + }; + + return ( + + + +
+ {!passkeys || passkeys.length === 0 ? ( +

+ {t("settings.noPasskeys")} +

+ ) : ( +
    + {passkeys.map((pk) => ( + +
    +
    + {editingId === pk.id ? ( +
    { + e.preventDefault(); + rename.mutate({ id: pk.id, name: editName.trim() }); + }} + > + setEditName(e.target.value)} + maxLength={64} + aria-label={t("settings.passkeyName")} + autoFocus + /> + + +
    + ) : ( + <> +

    + {pk.name} +

    +
    + + {DEVICE_TYPE_LABELS[pk.credentialDeviceType] ?? + pk.credentialDeviceType} + + + {pk.credentialBackedUp + ? t("settings.backedUp") + : t("common.no")} + + + {pk.lastUsedAt + ? t("settings.security.keys.lastUsed", { + date: formatDate(pk.lastUsedAt), + }) + : t("settings.security.keys.neverUsed")} + +
    + + )} +
    + + {editingId !== pk.id && ( +
    + + + + + + + + + {t("settings.deletePasskey")} + + + {t("settings.deletePasskeyDescription")} + + + + + {t("common.cancel")} + + remove.mutate(pk.id)} + > + {remove.isPending && ( + + )} + {t("common.delete")} + + + + +
    + )} +
    +
    + ))} +
+ )} + +
+ +
+ + {msg && ( +
+ + {msg} +
+ )} +
+
+ ); +} diff --git a/src/components/settings/security-section/passkey-upgrade-nudge.tsx b/src/components/settings/security-section/passkey-upgrade-nudge.tsx new file mode 100644 index 000000000..e1d1caa2a --- /dev/null +++ b/src/components/settings/security-section/passkey-upgrade-nudge.tsx @@ -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 ( +
+ +
+

+ {t("settings.security.passkeyNudge.title")} +

+

+ {t("settings.security.passkeyNudge.body")} +

+
+ +
+ ); +} diff --git a/src/components/settings/security-section/recovery-codes-panel.tsx b/src/components/settings/security-section/recovery-codes-panel.tsx new file mode 100644 index 000000000..858b0720d --- /dev/null +++ b/src/components/settings/security-section/recovery-codes-panel.tsx @@ -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 ( +
+
+ +

+ {t("settings.security.recovery.saveNow")} +

+
+

+ {t("settings.security.recovery.saveNowHint")} +

+
    + {codes.map((code) => ( +
  • + {code} +
  • + ))} +
+
+ + +
+
+ ); +} diff --git a/src/components/settings/security-section/security-keys-card.tsx b/src/components/settings/security-section/security-keys-card.tsx new file mode 100644 index 000000000..974df7b95 --- /dev/null +++ b/src/components/settings/security-section/security-keys-card.tsx @@ -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(null); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(""); + + const add = useMutation({ + mutationFn: async () => { + const { startRegistration } = await import("@simplewebauthn/browser"); + const { options, challengeId } = await apiPost<{ + options: Parameters[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 ( + + + +
+ {keys.length === 0 ? ( +

+ {t("settings.security.keys.empty")} +

+ ) : ( +
    + {keys.map((key) => ( + +
    +
    + {editingId === key.id ? ( +
    { + e.preventDefault(); + rename.mutate({ id: key.id, name: editName.trim() }); + }} + > + setEditName(e.target.value)} + maxLength={64} + aria-label={t("settings.security.keys.nameLabel")} + autoFocus + /> + + +
    + ) : ( + <> +

    + {key.name} +

    +

    + {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), + })} +

    + + )} +
    + + {editingId !== key.id && ( +
    + + + + + + + + + {t("settings.security.keys.remove")} + + + {t("settings.security.keys.removeConfirm")} + + + + + {t("common.cancel")} + + remove.mutate(key.id)} + > + {remove.isPending && ( + + )} + {t("common.delete")} + + + + +
    + )} +
    +
    + ))} +
+ )} + +
+ +
+ + {error && ( +
+ + {error} +
+ )} +
+
+ ); +} diff --git a/src/components/settings/security-section/totp-card.tsx b/src/components/settings/security-section/totp-card.tsx new file mode 100644 index 000000000..80e19d06b --- /dev/null +++ b/src/components/settings/security-section/totp-card.tsx @@ -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(null); + const [qrDataUrl, setQrDataUrl] = useState(null); + const [code, setCode] = useState(""); + const [secretCopied, setSecretCopied] = useState(false); + const [freshCodes, setFreshCodes] = useState(null); + const [error, setError] = useState(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("/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("/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 ( + + {t("settings.security.active")} + ) : ( + {t("settings.security.inactive")} + ) + } + /> + +
+ {/* ── Not enabled, no wizard: the enable CTA ── */} + {!enabled && !setup && !freshCodes && ( + + )} + + {/* ── Enable wizard ── */} + {setup && ( +
+
    +
  1. {t("settings.security.totp.step1")}
  2. +
  3. {t("settings.security.totp.step2")}
  4. +
+ +
+ {qrDataUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {t("settings.security.totp.qrAlt")} + ) : ( +
+ {t("settings.security.totp.qrUnavailable")} +
+ )} +
+

+ {t("settings.security.totp.manualEntry")} +

+
+ + {setup.totpSecret} + + +
+
+
+ +
{ + e.preventDefault(); + confirm.mutate(); + }} + > + +
+ + 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} + /> + +
+ +
+
+ )} + + {/* ── Fresh recovery codes (post-confirm or post-regen) ── */} + {freshCodes && ( +
+ +
+ +
+
+ )} + + {/* ── Enabled, idle: recovery status + disable/regen actions ── */} + {enabled && !freshCodes && ( +
+

+ {t( + recoveryCodesRemaining === 1 + ? "settings.security.recovery.remainingOne" + : "settings.security.recovery.remainingOther", + { count: recoveryCodesRemaining }, + )} +

+
+ + + + + + + + + + {t("settings.security.totp.disableTitle")} + + + {t("settings.security.totp.disableConfirm")} + + +
+ + setDisableCode(e.target.value)} + inputMode={disableMethod === "totp" ? "numeric" : "text"} + autoComplete="one-time-code" + placeholder={ + disableMethod === "totp" ? "123456" : "XXXXX-XXXXX" + } + className="font-mono" + /> + +
+ + {t("common.cancel")} + { + e.preventDefault(); + disable.mutate(); + }} + > + {disable.isPending && ( + + )} + {t("settings.security.totp.disable")} + + +
+
+
+
+ )} + + {error && ( +
+ + {error} +
+ )} +
+
+ ); +} diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 413b1cb20..2412f53d2 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -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. { diff --git a/src/lib/__tests__/require-fresh-mfa.test.ts b/src/lib/__tests__/require-fresh-mfa.test.ts index 8093ab688..308df3044 100644 --- a/src/lib/__tests__/require-fresh-mfa.test.ts +++ b/src/lib/__tests__/require-fresh-mfa.test.ts @@ -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) => diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index e95b4ca80..cb5886e38 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -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 { 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; diff --git a/src/lib/auth/mfa/webauthn.ts b/src/lib/auth/mfa/webauthn.ts new file mode 100644 index 000000000..aa5dc36ef --- /dev/null +++ b/src/lib/auth/mfa/webauthn.ts @@ -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 { + 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 { + 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(() => {}); + } +} diff --git a/src/lib/auth/passkey.ts b/src/lib/auth/passkey.ts index 4ebb661a0..3aa980ba1 100644 --- a/src/lib/auth/passkey.ts +++ b/src/lib/auth/passkey.ts @@ -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(), + }, }); } diff --git a/src/lib/auth/webauthn-rp.ts b/src/lib/auth/webauthn-rp.ts new file mode 100644 index 000000000..ba61f0192 --- /dev/null +++ b/src/lib/auth/webauthn-rp.ts @@ -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; +} diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 959913b13..96d39d5fc 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -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 = { "/api/auth/login": { post: { @@ -327,6 +364,176 @@ export const authPaths: NonNullable = { }, }, }, + "/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"], diff --git a/src/lib/query-keys/auth.ts b/src/lib/query-keys/auth.ts index 1e5836a0b..f16bd68ab 100644 --- a/src/lib/query-keys/auth.ts +++ b/src/lib/query-keys/auth.ts @@ -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, /** diff --git a/src/lib/validations/mfa.ts b/src/lib/validations/mfa.ts index c4a4a1a04..69c0cb621 100644 --- a/src/lib/validations/mfa.ts +++ b/src/lib/validations/mfa.ts @@ -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.", + });