From 135bb04f0db9ce097bb3b006661fae1741aff4dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 21:49:39 +0200 Subject: [PATCH] feat(auth): allow trusting a device to skip the second factor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an opt-in "remember this device" at the second-factor step. On opt-in a random token is minted, set in an httpOnly+Secure+SameSite=Strict cookie, and only its HMAC hash is stored in a new TrustedDevice table. A later sign-in from the same browser still requires the password (factor 1); a matching, unexpired trusted device lets the login skip factor 2 only. The token is never accepted for step-up — a trusted-device login does not stamp mfaVerifiedAt — so a stolen device cookie cannot reach a destructive action. Trusted devices are listed and revocable from the security surface, are revoked wholesale when the second factor is removed or the user signs out everywhere, and cascade away on account deletion. A recovery-code login never trusts the browser. Migration 0204 adds the table (and the instance-wide mfa_required policy column that the enforcement work reads). --- docs/api/openapi.yaml | 168 ++++++++++++++++ messages/de.json | 15 +- messages/en.json | 15 +- messages/es.json | 15 +- messages/fr.json | 15 +- messages/it.json | 15 +- messages/pl.json | 15 +- .../0204_v123_trusted_devices/migration.sql | 35 ++++ prisma/schema.prisma | 40 ++++ .../api/auth/login/mfa-branch.test.ts | 5 + .../auth/login/__tests__/native-token.test.ts | 8 + .../login/__tests__/trusted-device.test.ts | 125 ++++++++++++ src/app/api/auth/login/route.ts | 41 ++++ .../api/auth/me/trusted-devices/[id]/route.ts | 35 ++++ src/app/api/auth/me/trusted-devices/route.ts | 57 ++++++ src/app/api/auth/mfa/verify/route.ts | 19 +- .../webauthn/verify/__tests__/route.test.ts | 5 + src/app/api/auth/mfa/webauthn/verify/route.ts | 15 +- src/components/auth/mfa-login-step.tsx | 24 +++ .../settings/security-section/index.tsx | 3 + .../settings/trusted-devices-card.tsx | 172 +++++++++++++++++ src/lib/auth/__tests__/trusted-device.test.ts | 146 ++++++++++++++ src/lib/auth/session.ts | 8 + src/lib/auth/trusted-device.ts | 182 ++++++++++++++++++ src/lib/openapi/routes/auth.ts | 88 +++++++++ src/lib/query-keys/auth.ts | 7 + src/lib/validations/mfa.ts | 12 ++ 27 files changed, 1277 insertions(+), 8 deletions(-) create mode 100644 prisma/migrations/0204_v123_trusted_devices/migration.sql create mode 100644 src/app/api/auth/login/__tests__/trusted-device.test.ts create mode 100644 src/app/api/auth/me/trusted-devices/[id]/route.ts create mode 100644 src/app/api/auth/me/trusted-devices/route.ts create mode 100644 src/components/settings/trusted-devices-card.tsx create mode 100644 src/lib/auth/__tests__/trusted-device.test.ts create mode 100644 src/lib/auth/trusted-device.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 06d5b0a18..d3701cc56 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1779,6 +1779,60 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/auth/me/trusted-devices: + get: + tags: + - Auth + summary: List trusted devices + description: v1.23 — the 'remember this device' list. A trusted device skips the second factor for 30 days (the password + is still required). Returns only an IP-free device label + lifecycle timestamps, never the token. + responses: + "200": + description: Trusted devices for the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/TrustedDeviceListEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + delete: + tags: + - Auth + summary: Forget every trusted device + description: v1.23 — revokes all trusted devices for the caller and clears the caller's own trusted-device cookie. + responses: + "200": + description: All trusted devices revoked. + content: + application/json: + schema: + $ref: "#/components/schemas/TrustedDeviceRevokeAllEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/auth/me/trusted-devices/{id}: + delete: + tags: + - Auth + summary: Revoke a single trusted device + description: v1.23 — revokes one trusted device by id, scoped to the authenticated user (a foreign id returns 404). + responses: + "200": + description: Trusted device revoked. + content: + application/json: + schema: + $ref: "#/components/schemas/TrustedDeviceRevokeEnvelope" + "401": *a1 + "404": + description: Trusted device not found or not owned by the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/auth/me/security-activity: get: tags: @@ -7883,6 +7937,10 @@ components: type: string minLength: 6 maxLength: 32 + rememberDevice: + description: Opt in to trusting this device for 30 days — subsequent logins skip the second factor (the password is + still required). + type: boolean required: - mfaTicket - code @@ -8006,6 +8064,10 @@ components: - type - response description: SimpleWebAuthn ceremony response payload. + rememberDevice: + description: Opt in to trusting this device for 30 days — subsequent logins skip the second factor (the password is + still required). + type: boolean required: - mfaTicket - challengeId @@ -11685,6 +11747,112 @@ components: - data - error additionalProperties: false + TrustedDeviceListEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/TrustedDeviceListResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + TrustedDeviceListResponse: + type: object + properties: + devices: + type: array + items: + type: object + properties: + id: + type: string + label: + anyOf: + - type: string + - type: "null" + description: Coarse, IP-free device label (e.g. 'Firefox on macOS'). + 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: + 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)))$ + expiresAt: + 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)))$ + isCurrent: + type: boolean + description: True for the device making this request. + required: + - id + - label + - createdAt + - lastUsedAt + - expiresAt + - isCurrent + additionalProperties: false + required: + - devices + additionalProperties: false + TrustedDeviceRevokeAllEnvelope: + type: object + properties: + data: + type: object + properties: + revoked: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - revoked + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + TrustedDeviceRevokeEnvelope: + type: object + properties: + data: + type: object + properties: + revoked: + type: boolean + required: + - revoked + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false SecurityActivityEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index 2111762c8..5fe384c84 100644 --- a/messages/de.json +++ b/messages/de.json @@ -276,7 +276,8 @@ "useSecurityKey": "Sicherheitsschlüssel verwenden", "useRecoveryCode": "Wiederherstellungscode verwenden", "useAuthenticator": "Authenticator-App verwenden", - "backToLogin": "Zurück zur Anmeldung" + "backToLogin": "Zurück zur Anmeldung", + "rememberDevice": "Diesem Gerät 30 Tage vertrauen" } }, "dashboard": { @@ -5074,6 +5075,18 @@ "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." + }, + "trustedDevices": { + "title": "Vertrauenswürdige Geräte", + "description": "Browser, denen du vertraust, überspringen den zweiten Faktor für 30 Tage. Das Passwort wird weiterhin verlangt. Entziehe das Vertrauen, wenn du ein Gerät nicht kennst.", + "loadError": "Vertrauenswürdige Geräte konnten nicht geladen werden.", + "revokeError": "Gerät konnte nicht entfernt werden. Bitte erneut versuchen.", + "revoke": "Entfernen", + "revokeAll": "Alle Geräte vergessen", + "revokeAllDone": "{count} vertrauenswürdige(s) Gerät(e) entfernt.", + "unnamed": "Unbekanntes Gerät", + "current": "Dieses Gerät", + "expires": "Vertraut bis {date}" } } }, diff --git a/messages/en.json b/messages/en.json index dd4350467..52b7f58fd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -276,7 +276,8 @@ "useSecurityKey": "Use a security key", "useRecoveryCode": "Use a recovery code", "useAuthenticator": "Use your authenticator app", - "backToLogin": "Back to login" + "backToLogin": "Back to login", + "rememberDevice": "Trust this device for 30 days" } }, "dashboard": { @@ -5074,6 +5075,18 @@ "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." + }, + "trustedDevices": { + "title": "Trusted devices", + "description": "Browsers you chose to trust skip the second factor for 30 days. The password is still required. Revoke any you don't recognize.", + "loadError": "Could not load trusted devices.", + "revokeError": "Could not revoke the device. Please try again.", + "revoke": "Revoke", + "revokeAll": "Forget all devices", + "revokeAllDone": "Forgot {count} trusted device(s).", + "unnamed": "Unknown device", + "current": "This device", + "expires": "Trusted until {date}" } } }, diff --git a/messages/es.json b/messages/es.json index 54b47d881..940d98362 100644 --- a/messages/es.json +++ b/messages/es.json @@ -276,7 +276,8 @@ "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" + "backToLogin": "Volver al inicio de sesión", + "rememberDevice": "Confiar en este dispositivo durante 30 días" } }, "dashboard": { @@ -5074,6 +5075,18 @@ "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." + }, + "trustedDevices": { + "title": "Dispositivos de confianza", + "description": "Los navegadores en los que confías omiten el segundo factor durante 30 días. La contraseña sigue siendo obligatoria. Revoca los que no reconozcas.", + "loadError": "No se pudieron cargar los dispositivos de confianza.", + "revokeError": "No se pudo revocar el dispositivo. Inténtalo de nuevo.", + "revoke": "Revocar", + "revokeAll": "Olvidar todos los dispositivos", + "revokeAllDone": "Se olvidaron {count} dispositivo(s) de confianza.", + "unnamed": "Dispositivo desconocido", + "current": "Este dispositivo", + "expires": "De confianza hasta el {date}" } } }, diff --git a/messages/fr.json b/messages/fr.json index 3a9e226f9..aff7fe265 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -276,7 +276,8 @@ "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" + "backToLogin": "Retour à la connexion", + "rememberDevice": "Faire confiance à cet appareil pendant 30 jours" } }, "dashboard": { @@ -5074,6 +5075,18 @@ "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." + }, + "trustedDevices": { + "title": "Appareils de confiance", + "description": "Les navigateurs auxquels vous faites confiance ignorent le second facteur pendant 30 jours. Le mot de passe reste requis. Révoquez ceux que vous ne reconnaissez pas.", + "loadError": "Impossible de charger les appareils de confiance.", + "revokeError": "Impossible de révoquer l'appareil. Veuillez réessayer.", + "revoke": "Révoquer", + "revokeAll": "Oublier tous les appareils", + "revokeAllDone": "{count} appareil(s) de confiance oublié(s).", + "unnamed": "Appareil inconnu", + "current": "Cet appareil", + "expires": "De confiance jusqu'au {date}" } } }, diff --git a/messages/it.json b/messages/it.json index 6af687d8c..a24307993 100644 --- a/messages/it.json +++ b/messages/it.json @@ -276,7 +276,8 @@ "useSecurityKey": "Usa una chiave di sicurezza", "useRecoveryCode": "Usa un codice di recupero", "useAuthenticator": "Usa la tua app di autenticazione", - "backToLogin": "Torna all'accesso" + "backToLogin": "Torna all'accesso", + "rememberDevice": "Considera attendibile questo dispositivo per 30 giorni" } }, "dashboard": { @@ -5074,6 +5075,18 @@ "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." + }, + "trustedDevices": { + "title": "Dispositivi attendibili", + "description": "I browser che hai scelto di considerare attendibili saltano il secondo fattore per 30 giorni. La password è comunque richiesta. Revoca quelli che non riconosci.", + "loadError": "Impossibile caricare i dispositivi attendibili.", + "revokeError": "Impossibile revocare il dispositivo. Riprova.", + "revoke": "Revoca", + "revokeAll": "Dimentica tutti i dispositivi", + "revokeAllDone": "Dimenticati {count} dispositivo/i attendibile/i.", + "unnamed": "Dispositivo sconosciuto", + "current": "Questo dispositivo", + "expires": "Attendibile fino al {date}" } } }, diff --git a/messages/pl.json b/messages/pl.json index 90061eaca..15b46d6a6 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -276,7 +276,8 @@ "useSecurityKey": "Użyj klucza bezpieczeństwa", "useRecoveryCode": "Użyj kodu odzyskiwania", "useAuthenticator": "Użyj aplikacji uwierzytelniającej", - "backToLogin": "Powrót do logowania" + "backToLogin": "Powrót do logowania", + "rememberDevice": "Zaufaj temu urządzeniu na 30 dni" } }, "dashboard": { @@ -5074,6 +5075,18 @@ "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." + }, + "trustedDevices": { + "title": "Zaufane urządzenia", + "description": "Przeglądarki, którym zaufałeś, pomijają drugi składnik przez 30 dni. Hasło jest nadal wymagane. Cofnij zaufanie tym, których nie rozpoznajesz.", + "loadError": "Nie udało się załadować zaufanych urządzeń.", + "revokeError": "Nie udało się cofnąć urządzenia. Spróbuj ponownie.", + "revoke": "Cofnij", + "revokeAll": "Zapomnij wszystkie urządzenia", + "revokeAllDone": "Zapomniano {count} zaufane urządzenie/a.", + "unnamed": "Nieznane urządzenie", + "current": "To urządzenie", + "expires": "Zaufane do {date}" } } }, diff --git a/prisma/migrations/0204_v123_trusted_devices/migration.sql b/prisma/migrations/0204_v123_trusted_devices/migration.sql new file mode 100644 index 000000000..6cffaf903 --- /dev/null +++ b/prisma/migrations/0204_v123_trusted_devices/migration.sql @@ -0,0 +1,35 @@ +-- v1.23 — admin-enforced MFA policy + "remember this device" trusted devices. +-- +-- 1. `app_settings.mfa_required` — the instance-wide "require a second factor" +-- policy. When true, every account without an active second factor is sent +-- through a forced-enrollment interstitial after sign-in. The effective +-- per-user requirement is `mfa_required OR users.mfa_enforced`. +-- +-- 2. `trusted_devices` — the opt-in "remember this device" store. Only the HMAC +-- hash of the cookie token is persisted (keyed by API_TOKEN_HMAC_KEY); the +-- raw token lives only in the user's httpOnly+Secure cookie. A trusted +-- device skips factor 2 within the 30-day window but never satisfies step-up +-- and never replaces the password. Revoked when the second factor is removed. + +ALTER TABLE "app_settings" ADD COLUMN "mfa_required" BOOLEAN NOT NULL DEFAULT false; + +CREATE TABLE "trusted_devices" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "label" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + "last_used_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "trusted_devices_pkey" PRIMARY KEY ("id") +); + +-- The token hash is the lookup key on every trusted-device sign-in, and a +-- token is single-purpose per device, so the hash is globally unique. +CREATE UNIQUE INDEX "trusted_devices_token_hash_key" ON "trusted_devices"("token_hash"); +CREATE INDEX "trusted_devices_user_id_idx" ON "trusted_devices"("user_id"); +-- The expired-trusted-device reaper sweeps on `expires_at`. +CREATE INDEX "trusted_devices_expires_at_idx" ON "trusted_devices"("expires_at"); + +ALTER TABLE "trusted_devices" ADD CONSTRAINT "trusted_devices_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3172fb5e0..01c290de9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -576,6 +576,10 @@ model User { mfaRecoveryCodes MfaRecoveryCode[] webauthnMfaCredentials WebauthnMfaCredential[] knownDevices UserKnownDevice[] + // v1.23 — "remember this device" trusted-device tokens. A trusted device + // skips the second factor (factor 2 only) within the window; the password + // is still required. Revoked when the second factor is removed. + trustedDevices TrustedDevice[] measurements Measurement[] medications Medication[] intakeEvents MedicationIntakeEvent[] @@ -902,6 +906,34 @@ model UserKnownDevice { @@map("user_known_devices") } +// v1.23 — "remember this device" trusted-device token. +// +// At the second-factor step the user may opt in to trusting the browser for 30 +// days. On opt-in a random token is minted, set in an httpOnly+Secure cookie, +// and only its HMAC hash (`tokenHash`, keyed by `API_TOKEN_HMAC_KEY`) is stored +// here — a leaked row cannot reconstruct a usable cookie. On a later sign-in +// from the same browser the password is still required (factor 1); a matching, +// unexpired trusted-device row lets the login skip factor 2 only. The token is +// NEVER accepted for step-up (`requireFreshMfa`) — a stolen cookie cannot reach +// a destructive action. Revocable from the device list and revoked wholesale +// when the second factor is removed or the user signs out everywhere. +model TrustedDevice { + id String @id @default(cuid()) + userId String @map("user_id") + tokenHash String @unique @map("token_hash") + label String? + createdAt DateTime @default(now()) @map("created_at") + expiresAt DateTime @map("expires_at") + lastUsedAt DateTime @default(now()) @map("last_used_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + // The reaper for expired trusted devices sweeps on `expiresAt`. + @@index([expiresAt]) + @@map("trusted_devices") +} + model AuthChallenge { id String @id @default(cuid()) userId String? @map("user_id") // null for registration @@ -3216,6 +3248,14 @@ model IntegrationStatus { model AppSettings { id String @id @default("singleton") registrationEnabled Boolean @default(true) @map("registration_enabled") + // v1.23 — instance-wide "require a second factor" policy. When true, every + // account without an active second factor (no confirmed TOTP and no + // registered security key) is sent through a forced-enrollment interstitial + // after sign-in until it enrolls one. A per-user override lives on + // `User.mfaEnforced`; the effective requirement is the OR of the two. The + // operator/CLI escape hatch (`scripts/disable-mfa.ts`, toggling this off) + // keeps the policy from locking anyone out irrecoverably. + mfaRequired Boolean @default(false) @map("mfa_required") defaultLocale String @default("de") @map("default_locale") // "de" | "en" telegramGlobal Boolean @default(true) @map("telegram_global") // global toggle ntfyGlobal Boolean @default(true) @map("ntfy_global") // global toggle diff --git a/src/__tests__/api/auth/login/mfa-branch.test.ts b/src/__tests__/api/auth/login/mfa-branch.test.ts index 6e65e27eb..e59dffb9d 100644 --- a/src/__tests__/api/auth/login/mfa-branch.test.ts +++ b/src/__tests__/api/auth/login/mfa-branch.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +vi.mock("@/lib/auth/mfa-enrollment", () => ({ + setMfaEnrollCookie: vi.fn().mockResolvedValue(undefined), + syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/db", () => ({ prisma: { user: { findFirst: vi.fn() }, 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 24abf1edf..fc1212912 100644 --- a/src/app/api/auth/login/__tests__/native-token.test.ts +++ b/src/app/api/auth/login/__tests__/native-token.test.ts @@ -17,6 +17,14 @@ vi.mock("@/lib/auth/password", () => ({ verifyPassword: vi.fn(), })); +vi.mock("@/lib/auth/mfa-enrollment", () => ({ + syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/auth/login-alert", () => ({ + recordSignInDevice: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/auth/session", () => ({ createSession: vi.fn().mockResolvedValue("session-id"), setOnboardingPendingCookie: vi.fn().mockResolvedValue(undefined), diff --git a/src/app/api/auth/login/__tests__/trusted-device.test.ts b/src/app/api/auth/login/__tests__/trusted-device.test.ts new file mode 100644 index 000000000..cb895be6b --- /dev/null +++ b/src/app/api/auth/login/__tests__/trusted-device.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findFirst: vi.fn() }, + webauthnMfaCredential: { count: vi.fn().mockResolvedValue(0) }, + }, +})); + +vi.mock("@/lib/auth/password", () => ({ verifyPassword: vi.fn() })); + +vi.mock("@/lib/auth/login-response", () => ({ + finishLogin: vi.fn(async () => new Response(null, { status: 200 })), +})); + +vi.mock("@/lib/auth/mfa/challenge", () => ({ + createMfaChallenge: vi.fn(async () => ({ + ticket: "tkt", + expiresAt: new Date(), + })), +})); + +vi.mock("@/lib/auth/trusted-device", () => ({ + consumeTrustedDevice: vi.fn(), +})); + +vi.mock("@/lib/auth/mfa-enrollment", () => ({ + syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }), + checkAuthSurfaceRateLimit: vi + .fn() + .mockResolvedValue({ allowed: true, ip: "1.2.3.4" }), + rateLimitHeaders: vi.fn(() => ({})), +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn(() => "h") })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { verifyPassword } from "@/lib/auth/password"; +import { finishLogin } from "@/lib/auth/login-response"; +import { createMfaChallenge } from "@/lib/auth/mfa/challenge"; +import { consumeTrustedDevice } from "@/lib/auth/trusted-device"; + +const MFA_USER = { + id: "u1", + username: "u", + passwordHash: "hash", + onboardingCompletedAt: null, + totpConfirmedAt: new Date(), + mfaEnforced: false, +}; + +function req() { + return new NextRequest("http://localhost/api/auth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email: "u@example.com", password: "pw-correct" }), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.user.findFirst).mockResolvedValue(MFA_USER as never); + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never); +}); + +describe("POST /api/auth/login — trusted device", () => { + it("skips the second factor when the device is trusted (no challenge minted)", async () => { + vi.mocked(verifyPassword).mockResolvedValue(true); + vi.mocked(consumeTrustedDevice).mockResolvedValue(true); + + const res = await POST(req()); + expect(res.status).toBe(200); + expect(createMfaChallenge).not.toHaveBeenCalled(); + expect(finishLogin).toHaveBeenCalledWith( + expect.objectContaining({ source: "login.password.trusted_device" }), + ); + // A trusted-device login is NOT step-up fresh — mfaVerified must be absent. + const arg = vi.mocked(finishLogin).mock.calls[0][0]; + expect(arg.mfaVerified).toBeUndefined(); + }); + + it("falls back to the MFA challenge when the device is not trusted", async () => { + vi.mocked(verifyPassword).mockResolvedValue(true); + vi.mocked(consumeTrustedDevice).mockResolvedValue(false); + + const res = await POST(req()); + expect(res.status).toBe(200); + const body = (await res.json()) as { meta?: { mfaRequired?: boolean } }; + expect(body.meta?.mfaRequired).toBe(true); + expect(createMfaChallenge).toHaveBeenCalled(); + expect(finishLogin).not.toHaveBeenCalled(); + }); + + it("still requires the password — a wrong password never consults the trusted device", async () => { + vi.mocked(verifyPassword).mockResolvedValue(false); + + const res = await POST(req()); + expect(res.status).toBe(401); + expect(consumeTrustedDevice).not.toHaveBeenCalled(); + expect(finishLogin).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 359d81777..8093c3a80 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -11,6 +11,8 @@ import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { finishLogin } from "@/lib/auth/login-response"; import { createMfaChallenge } from "@/lib/auth/mfa/challenge"; +import { consumeTrustedDevice } from "@/lib/auth/trusted-device"; +import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; export const POST = apiHandler(async (request: NextRequest) => { // v1.4.43 W13 M-4 — `checkAuthSurfaceRateLimit` swaps to a tighter @@ -114,6 +116,37 @@ export const POST = apiHandler(async (request: NextRequest) => { const hasWebauthn = webauthnKeyCount > 0; if (hasTotp || hasWebauthn) { + // v1.23 — "remember this device". A browser the user previously trusted + // skips factor 2 within the 30-day window. The password (factor 1) has + // already been verified above, so a trusted device only ever drops the + // second challenge — it never replaces the password. The minted session is + // deliberately NOT stamped `mfaVerifiedAt` (mfaVerified omitted below), so a + // trusted-device login can never satisfy step-up (`requireFreshMfa`): a + // stolen device cookie cannot reach a destructive action. + if (await consumeTrustedDevice(user.id)) { + await auditLog("auth.mfa.trusted_device", { + userId: user.id, + ipAddress: ip, + }); + annotate({ + action: { name: "auth.mfa.trusted_device" }, + meta: { mfa_skipped: true }, + }); + // The account has a second factor, so the enforcement gate is satisfied; + // keep the hint cookie honest. + await syncMfaEnrollCookie(user.id, { + totpConfirmedAt: user.totpConfirmedAt, + mfaEnforced: user.mfaEnforced, + }); + return finishLogin({ + user, + request, + ip, + userAgent: ua, + source: "login.password.trusted_device", + }); + } + 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. @@ -145,6 +178,14 @@ export const POST = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "auth.login.password" } }); + // v1.23 — admin-enforced MFA policy. A single-factor account under the policy + // is sent to forced enrollment after sign-in; the proxy reads this hint + // cookie without a DB round-trip. Cheap (short-circuits when not enforced). + await syncMfaEnrollCookie(user.id, { + totpConfirmedAt: user.totpConfirmedAt, + mfaEnforced: user.mfaEnforced, + }); + // No second factor — issue the session/token exactly as before. return finishLogin({ user, diff --git a/src/app/api/auth/me/trusted-devices/[id]/route.ts b/src/app/api/auth/me/trusted-devices/[id]/route.ts new file mode 100644 index 000000000..82f511c1d --- /dev/null +++ b/src/app/api/auth/me/trusted-devices/[id]/route.ts @@ -0,0 +1,35 @@ +/** + * DELETE /api/auth/me/trusted-devices/[id] + * + * Revoke a single trusted device, scoped to the authenticated user (a foreign + * id returns 404, never another user's row). When the revoked device is the + * caller's own, the trusted-device cookie is cleared too. Cookie-only. + */ +import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { revokeTrustedDevice } from "@/lib/auth/trusted-device"; + +export const dynamic = "force-dynamic"; + +export const DELETE = apiHandler( + async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const { user } = await requireCookieAuth(); + const { id } = await params; + + const revoked = await revokeTrustedDevice(user.id, id); + if (!revoked) { + annotate({ action: { name: "auth.trusted_device.revoke.not_found" } }); + return apiError("Trusted device not found", 404); + } + + await auditLog("auth.trusted_device.revoke", { + userId: user.id, + ipAddress: getClientIp(req), + }); + annotate({ action: { name: "auth.trusted_device.revoke" } }); + + return apiSuccess({ revoked: true }); + }, +); diff --git a/src/app/api/auth/me/trusted-devices/route.ts b/src/app/api/auth/me/trusted-devices/route.ts new file mode 100644 index 000000000..228c91ef5 --- /dev/null +++ b/src/app/api/auth/me/trusted-devices/route.ts @@ -0,0 +1,57 @@ +/** + * GET /api/auth/me/trusted-devices — list the caller's trusted devices. + * DELETE /api/auth/me/trusted-devices — revoke ALL of them ("forget every + * device"); also clears the caller's own trusted-device cookie. + * + * Cookie-only (`requireCookieAuth`) — a trusted device is a browser concept and + * a Bearer client never manages one. Returns only metadata: a coarse, IP-free + * device label and the lifecycle timestamps; never the token or its hash. + */ +import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { getClientIp } from "@/lib/api-response"; +import { + listTrustedDevices, + revokeAllTrustedDevices, + clearTrustedDeviceCookie, +} from "@/lib/auth/trusted-device"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireCookieAuth(); + annotate({ action: { name: "auth.trusted_device.list" } }); + + const devices = await listTrustedDevices(user.id); + return apiSuccess({ + devices: devices.map((d) => ({ + id: d.id, + label: d.label, + createdAt: d.createdAt.toISOString(), + lastUsedAt: d.lastUsedAt.toISOString(), + expiresAt: d.expiresAt.toISOString(), + isCurrent: d.isCurrent, + })), + }); +}); + +export const DELETE = apiHandler(async (req: Request) => { + const { user } = await requireCookieAuth(); + + const revoked = await revokeAllTrustedDevices(user.id); + await clearTrustedDeviceCookie(); + + await auditLog("auth.trusted_device.revoke_all", { + userId: user.id, + ipAddress: getClientIp(req), + details: { revoked }, + }); + annotate({ + action: { name: "auth.trusted_device.revoke_all" }, + meta: { revoked }, + }); + + return apiSuccess({ revoked }); +}); diff --git a/src/app/api/auth/mfa/verify/route.ts b/src/app/api/auth/mfa/verify/route.ts index 4e0829821..67a4028d9 100644 --- a/src/app/api/auth/mfa/verify/route.ts +++ b/src/app/api/auth/mfa/verify/route.ts @@ -32,6 +32,9 @@ import { } from "@/lib/auth/mfa/challenge"; import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor"; import { mfaVerifySchema } from "@/lib/validations/mfa"; +import { mintTrustedDevice } from "@/lib/auth/trusted-device"; +import { coarseDeviceLabel } from "@/lib/auth/device-fingerprint"; +import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; export const dynamic = "force-dynamic"; @@ -60,7 +63,7 @@ export const POST = apiHandler(async (request: NextRequest) => { if (!parsed.success) { return apiError("Invalid request", 422); } - const { mfaTicket, method, code } = parsed.data; + const { mfaTicket, method, code, rememberDevice } = parsed.data; // Resolve the ticket. A single generic 401 covers unknown / expired / // consumed / over-cap so a ticket-guesser learns nothing. @@ -136,6 +139,20 @@ export const POST = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "auth.mfa.verified" }, meta: { method } }); const ua = request.headers.get("user-agent"); + + // The user just completed a second factor, so any forced-enrollment redirect + // must clear immediately. + await setMfaEnrollCookie(false); + + // v1.23 — "remember this device". Mint the trusted-device token + cookie + // (only its hash is stored) so subsequent logins from this browser skip the + // second factor for 30 days. Set BEFORE finishLogin so both cookies land on + // the same response. A recovery-code login is excluded: it signals the user + // lost their device, so trusting the current browser would be unsafe. + if (rememberDevice && method !== "recovery") { + await mintTrustedDevice(user.id, coarseDeviceLabel(ua)); + } + return finishLogin({ user, request, 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 index e53875cb5..14815ff0c 100644 --- a/src/app/api/auth/mfa/webauthn/verify/__tests__/route.test.ts +++ b/src/app/api/auth/mfa/webauthn/verify/__tests__/route.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +vi.mock("@/lib/auth/mfa-enrollment", () => ({ + setMfaEnrollCookie: vi.fn().mockResolvedValue(undefined), + syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/api-handler", () => ({ apiHandler: (fn: unknown) => fn, })); diff --git a/src/app/api/auth/mfa/webauthn/verify/route.ts b/src/app/api/auth/mfa/webauthn/verify/route.ts index 875105c6b..c7ba603e5 100644 --- a/src/app/api/auth/mfa/webauthn/verify/route.ts +++ b/src/app/api/auth/mfa/webauthn/verify/route.ts @@ -30,6 +30,9 @@ import { } from "@/lib/auth/mfa/challenge"; import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn"; import { mfaWebauthnLoginVerifySchema } from "@/lib/validations/mfa"; +import { mintTrustedDevice } from "@/lib/auth/trusted-device"; +import { coarseDeviceLabel } from "@/lib/auth/device-fingerprint"; +import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; export const dynamic = "force-dynamic"; @@ -58,7 +61,7 @@ export const POST = apiHandler(async (request: NextRequest) => { if (!parsed.success) { return apiError("Invalid request", 422); } - const { mfaTicket, challengeId, credential } = parsed.data; + const { mfaTicket, challengeId, credential, rememberDevice } = parsed.data; const challenge = await loadActiveChallenge(mfaTicket); if (!challenge) { @@ -116,6 +119,16 @@ export const POST = apiHandler(async (request: NextRequest) => { }); const ua = request.headers.get("user-agent"); + + // The user just completed a second factor — clear any forced-enrollment hint. + await setMfaEnrollCookie(false); + + // v1.23 — "remember this device": skip the second factor on this browser for + // 30 days. Set before finishLogin so both cookies share the response. + if (rememberDevice) { + await mintTrustedDevice(user.id, coarseDeviceLabel(ua)); + } + return finishLogin({ user, request, diff --git a/src/components/auth/mfa-login-step.tsx b/src/components/auth/mfa-login-step.tsx index 86bc86308..1377d9e27 100644 --- a/src/components/auth/mfa-login-step.tsx +++ b/src/components/auth/mfa-login-step.tsx @@ -4,6 +4,7 @@ import { useId, useState } from "react"; import { KeyRound, Loader2, Lock, ShieldCheck } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useTranslations } from "@/lib/i18n/context"; @@ -35,6 +36,7 @@ export function MfaLoginStep({ const { t } = useTranslations(); const codeFieldId = useId(); const errorId = useId(); + const rememberId = useId(); const hasTotp = methods.includes("totp"); const hasWebauthn = methods.includes("webauthn"); @@ -42,6 +44,9 @@ export function MfaLoginStep({ // 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); + // v1.23 — "remember this device" opt-in. Off by default (a 2FA-bypass token + // is opt-in only); a recovery-code login forces it off server-side. + const [rememberDevice, setRememberDevice] = useState(false); const [code, setCode] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -55,6 +60,7 @@ export function MfaLoginStep({ mfaTicket, method: useRecovery ? "recovery" : "totp", code: code.trim(), + rememberDevice: useRecovery ? false : rememberDevice, }); onSuccess(); } catch (err) { @@ -82,6 +88,7 @@ export function MfaLoginStep({ mfaTicket, challengeId, credential, + rememberDevice, }); onSuccess(); } catch (err) { @@ -169,6 +176,23 @@ export function MfaLoginStep({ )} + {/* v1.23 — "remember this device". Hidden on the recovery path (a + recovery-code login signals a lost device, so trusting the browser + would be unsafe — the server forces it off there too). */} + {!useRecovery && ( + + )} + {hasTotp && ( + + ))} + + )} + + {devices.length > 0 && ( +
+ +
+ )} + + {status && ( +

+ {status} +

+ )} + + + ); +} diff --git a/src/lib/auth/__tests__/trusted-device.test.ts b/src/lib/auth/__tests__/trusted-device.test.ts new file mode 100644 index 000000000..00007158a --- /dev/null +++ b/src/lib/auth/__tests__/trusted-device.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + trustedDevice: { + create: vi.fn(), + findUnique: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + deleteMany: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/hmac", () => ({ + hashToken: (raw: string) => `h:${raw}`, +})); + +vi.mock("@/lib/auth/secure-cookie", () => ({ + shouldEmitSecureCookie: () => true, +})); + +let cookieValue: string | undefined; +const cookieStore = { + get: vi.fn((name: string) => + name === "hl_trusted_device" && cookieValue !== undefined + ? { value: cookieValue } + : undefined, + ), + set: vi.fn(), + delete: vi.fn(), +}; +vi.mock("next/headers", () => ({ + cookies: vi.fn(async () => cookieStore), +})); + +import { + mintTrustedDevice, + consumeTrustedDevice, + revokeTrustedDevice, + revokeAllTrustedDevices, +} from "../trusted-device"; +import { prisma } from "@/lib/db"; + +beforeEach(() => { + vi.clearAllMocks(); + cookieValue = undefined; +}); + +describe("mintTrustedDevice", () => { + it("stores only the token hash and sets an httpOnly, secure cookie", async () => { + vi.mocked(prisma.trustedDevice.create).mockResolvedValue({ + id: "td1", + expiresAt: new Date(), + } as never); + + await mintTrustedDevice("u1", "Firefox on macOS"); + + const createArg = vi.mocked(prisma.trustedDevice.create).mock.calls[0][0]; + expect(createArg.data.userId).toBe("u1"); + expect(createArg.data.tokenHash.startsWith("h:")).toBe(true); + // The raw token is never persisted — only the "h:" hash. + expect(createArg.data.tokenHash).not.toBe(createArg.data.label); + + expect(cookieStore.set).toHaveBeenCalledWith( + "hl_trusted_device", + expect.any(String), + expect.objectContaining({ httpOnly: true, secure: true }), + ); + }); +}); + +describe("consumeTrustedDevice", () => { + it("returns false when no cookie is present", async () => { + expect(await consumeTrustedDevice("u1")).toBe(false); + expect(prisma.trustedDevice.findUnique).not.toHaveBeenCalled(); + }); + + it("returns true for a live row owned by the user and bumps lastUsedAt", async () => { + cookieValue = "tok"; + vi.mocked(prisma.trustedDevice.findUnique).mockResolvedValue({ + id: "td1", + userId: "u1", + expiresAt: new Date(Date.now() + 60_000), + } as never); + vi.mocked(prisma.trustedDevice.update).mockResolvedValue({} as never); + + expect(await consumeTrustedDevice("u1")).toBe(true); + expect(prisma.trustedDevice.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: "td1" } }), + ); + }); + + it("returns false when the row belongs to a different user", async () => { + cookieValue = "tok"; + vi.mocked(prisma.trustedDevice.findUnique).mockResolvedValue({ + id: "td1", + userId: "someone-else", + expiresAt: new Date(Date.now() + 60_000), + } as never); + expect(await consumeTrustedDevice("u1")).toBe(false); + }); + + it("returns false and deletes the row when expired", async () => { + cookieValue = "tok"; + vi.mocked(prisma.trustedDevice.findUnique).mockResolvedValue({ + id: "td1", + userId: "u1", + expiresAt: new Date(Date.now() - 1000), + } as never); + vi.mocked(prisma.trustedDevice.deleteMany).mockResolvedValue({ + count: 1, + } as never); + expect(await consumeTrustedDevice("u1")).toBe(false); + expect(prisma.trustedDevice.deleteMany).toHaveBeenCalled(); + expect(cookieStore.delete).toHaveBeenCalledWith("hl_trusted_device"); + }); +}); + +describe("revoke", () => { + it("revokes a single device scoped to the user", async () => { + vi.mocked(prisma.trustedDevice.findFirst).mockResolvedValue({ + id: "td1", + tokenHash: "h:other", + } as never); + vi.mocked(prisma.trustedDevice.delete).mockResolvedValue({} as never); + expect(await revokeTrustedDevice("u1", "td1")).toBe(true); + expect(prisma.trustedDevice.delete).toHaveBeenCalledWith({ + where: { id: "td1" }, + }); + }); + + it("returns false for a foreign id", async () => { + vi.mocked(prisma.trustedDevice.findFirst).mockResolvedValue(null as never); + expect(await revokeTrustedDevice("u1", "nope")).toBe(false); + expect(prisma.trustedDevice.delete).not.toHaveBeenCalled(); + }); + + it("revokeAll returns the deleted count", async () => { + vi.mocked(prisma.trustedDevice.deleteMany).mockResolvedValue({ + count: 3, + } as never); + expect(await revokeAllTrustedDevices("u1")).toBe(3); + }); +}); diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 83295b4b8..61511b4f2 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -211,6 +211,10 @@ export async function destroyAllSessions(userId: string): Promise { where: { userId, revokedAt: null }, data: { revokedAt: new Date() }, }), + // v1.23 — a credential rotation also kills every "remember this device" + // trusted-device token: a stolen device cookie must not survive a + // password change or account-credential remediation. + prisma.trustedDevice.deleteMany({ where: { userId } }), ]); } @@ -238,6 +242,10 @@ export async function destroyOtherSessions( where: { userId, revokedAt: null }, data: { revokedAt: new Date() }, }), + // v1.23 — "sign out everywhere" also drops every trusted device so a + // remembered browser can no longer skip the second factor (§1.7: a device + // cookie is killed on sign-out-everywhere). + prisma.trustedDevice.deleteMany({ where: { userId } }), ]); return { sessionsRevoked: deleted.count }; } diff --git a/src/lib/auth/trusted-device.ts b/src/lib/auth/trusted-device.ts new file mode 100644 index 000000000..e93888a25 --- /dev/null +++ b/src/lib/auth/trusted-device.ts @@ -0,0 +1,182 @@ +/** + * "Remember this device" — the opt-in trusted-device token that lets a + * returning browser skip the second factor (factor 2 only) within a 30-day + * window. The password is ALWAYS still required; a trusted device never + * replaces factor 1 and is NEVER accepted for step-up (`requireFreshMfa`). + * + * Security shape (mirrors the refresh-token / MFA-ticket convention): + * - The cookie carries a 256-bit random token; only its HMAC hash + * (`hashToken`, keyed by `API_TOKEN_HMAC_KEY`) is stored. A leaked + * `trusted_devices` row cannot reconstruct a usable cookie. + * - The cookie is `httpOnly` (a bearer credential JS must never read), + * `Secure` (via `shouldEmitSecureCookie`), `SameSite=Strict` (no + * cross-site flow depends on it — the login POST is same-origin), bound to + * the user via the stored row, and capped at the row's `expiresAt`. + * - Bound to the user: `consumeTrustedDevice` matches the hash AND the userId, + * so one account's cookie can never satisfy another account's login. + * - Revocable: a single row delete, a per-user wipe (factor removed / sign-out + * everywhere), or the `onDelete: Cascade` on account deletion. + */ +import { randomBytes } from "node:crypto"; +import { cookies } from "next/headers"; +import { prisma } from "@/lib/db"; +import { hashToken } from "@/lib/auth/hmac"; +import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; +import type { Prisma } from "@/generated/prisma/client"; + +export const TRUSTED_DEVICE_COOKIE = "hl_trusted_device"; +/** 30-day trust window — the ceiling OWASP recommends for a device cookie. */ +export const TRUSTED_DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000; + +/** + * Mint a trusted-device row + set the cookie. Returns the created row id. + * `label` is a coarse, IP-free device hint for the device list (e.g. + * "Firefox on macOS"); never store the raw User-Agent or IP here. + */ +export async function mintTrustedDevice( + userId: string, + label: string | null, +): Promise<{ id: string; expiresAt: Date }> { + const token = randomBytes(32).toString("base64url"); + const expiresAt = new Date(Date.now() + TRUSTED_DEVICE_TTL_MS); + const row = await prisma.trustedDevice.create({ + data: { + userId, + tokenHash: hashToken(token), + label, + expiresAt, + }, + select: { id: true, expiresAt: true }, + }); + + const cookieStore = await cookies(); + cookieStore.set(TRUSTED_DEVICE_COOKIE, token, { + httpOnly: true, + secure: shouldEmitSecureCookie(), + // No cross-site flow depends on this cookie; the login POST is same-origin, + // so Strict is sent and gives the tightest CSRF posture. + sameSite: "strict", + maxAge: Math.floor(TRUSTED_DEVICE_TTL_MS / 1000), + path: "/", + }); + + return row; +} + +/** + * Resolve the trusted-device cookie against the user being signed in. Returns + * true only when a live (unexpired) row matches the cookie token's hash AND + * the userId. Bumps `lastUsedAt` on a hit. An expired row is deleted and + * treated as a miss; the password step has already happened by the time this + * is consulted. + */ +export async function consumeTrustedDevice(userId: string): Promise { + const cookieStore = await cookies(); + const token = cookieStore.get(TRUSTED_DEVICE_COOKIE)?.value; + if (!token) return false; + + const row = await prisma.trustedDevice.findUnique({ + where: { tokenHash: hashToken(token) }, + select: { id: true, userId: true, expiresAt: true }, + }); + if (!row || row.userId !== userId) return false; + + if (row.expiresAt.getTime() <= Date.now()) { + await prisma.trustedDevice + .deleteMany({ where: { id: row.id } }) + .catch(() => {}); + cookieStore.delete(TRUSTED_DEVICE_COOKIE); + return false; + } + + void prisma.trustedDevice + .update({ where: { id: row.id }, data: { lastUsedAt: new Date() } }) + .catch(() => {}); + return true; +} + +/** The hash of the caller's current trusted-device cookie, or null. */ +export async function currentTrustedDeviceHash(): Promise { + const cookieStore = await cookies(); + const token = cookieStore.get(TRUSTED_DEVICE_COOKIE)?.value; + return token ? hashToken(token) : null; +} + +export interface TrustedDeviceInfo { + id: string; + label: string | null; + createdAt: Date; + lastUsedAt: Date; + expiresAt: Date; + isCurrent: boolean; +} + +/** List the caller's live trusted devices, newest first, current one marked. */ +export async function listTrustedDevices( + userId: string, +): Promise { + const currentHash = await currentTrustedDeviceHash(); + const rows = await prisma.trustedDevice.findMany({ + where: { userId, expiresAt: { gt: new Date() } }, + select: { + id: true, + label: true, + createdAt: true, + lastUsedAt: true, + expiresAt: true, + tokenHash: true, + }, + orderBy: { lastUsedAt: "desc" }, + }); + return rows.map((r) => ({ + id: r.id, + label: r.label, + createdAt: r.createdAt, + lastUsedAt: r.lastUsedAt, + expiresAt: r.expiresAt, + isCurrent: currentHash !== null && r.tokenHash === currentHash, + })); +} + +/** + * Revoke one trusted device, scoped to the owning user (a foreign id removes + * nothing). Clears the cookie when the revoked row was the caller's own. + * Returns whether a row was removed. + */ +export async function revokeTrustedDevice( + userId: string, + id: string, +): Promise { + const currentHash = await currentTrustedDeviceHash(); + const row = await prisma.trustedDevice.findFirst({ + where: { id, userId }, + select: { id: true, tokenHash: true }, + }); + if (!row) return false; + await prisma.trustedDevice.delete({ where: { id: row.id } }); + if (currentHash !== null && row.tokenHash === currentHash) { + const cookieStore = await cookies(); + cookieStore.delete(TRUSTED_DEVICE_COOKIE); + } + return true; +} + +/** + * Revoke EVERY trusted device for a user. Called on a security-state change + * (second factor removed, sign-out-everywhere, password rotation). Optionally + * runs inside a caller transaction. Returns the count removed. + */ +export async function revokeAllTrustedDevices( + userId: string, + tx?: Prisma.TransactionClient, +): Promise { + const client = tx ?? prisma; + const result = await client.trustedDevice.deleteMany({ where: { userId } }); + return result.count; +} + +/** Clear the caller's trusted-device cookie without touching any rows. */ +export async function clearTrustedDeviceCookie(): Promise { + const cookieStore = await cookies(); + cookieStore.delete(TRUSTED_DEVICE_COOKIE); +} diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 96d39d5fc..5d1743c50 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -163,6 +163,26 @@ const sessionListResponse = z }) .meta({ id: "SessionListResponse" }); +const trustedDeviceListResponse = z + .object({ + devices: z.array( + z.object({ + id: z.string(), + label: z + .string() + .nullable() + .describe("Coarse, IP-free device label (e.g. 'Firefox on macOS')."), + createdAt: z.iso.datetime({ offset: true }), + lastUsedAt: z.iso.datetime({ offset: true }), + expiresAt: z.iso.datetime({ offset: true }), + isCurrent: z + .boolean() + .describe("True for the device making this request."), + }), + ), + }) + .meta({ id: "TrustedDeviceListResponse" }); + const signOutEverywhereResponse = z .object({ sessionsRevoked: z @@ -647,6 +667,74 @@ export const authPaths: NonNullable = { }, }, }, + "/api/auth/me/trusted-devices": { + get: { + tags: ["Auth"], + summary: "List trusted devices", + description: + "v1.23 — the 'remember this device' list. A trusted device skips the second factor for 30 days (the password is still required). Returns only an IP-free device label + lifecycle timestamps, never the token.", + responses: { + "200": { + description: "Trusted devices for the caller.", + content: { + "application/json": { + schema: dataEnvelope( + trustedDeviceListResponse, + "TrustedDeviceListEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Auth"], + summary: "Forget every trusted device", + description: + "v1.23 — revokes all trusted devices for the caller and clears the caller's own trusted-device cookie.", + responses: { + "200": { + description: "All trusted devices revoked.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ revoked: z.number().int() }), + "TrustedDeviceRevokeAllEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/trusted-devices/{id}": { + delete: { + tags: ["Auth"], + summary: "Revoke a single trusted device", + description: + "v1.23 — revokes one trusted device by id, scoped to the authenticated user (a foreign id returns 404).", + responses: { + "200": { + description: "Trusted device revoked.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ revoked: z.boolean() }), + "TrustedDeviceRevokeEnvelope", + ), + }, + }, + }, + "404": { + description: "Trusted device not found or not owned by the caller.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, "/api/auth/me/security-activity": { get: { tags: ["Auth"], diff --git a/src/lib/query-keys/auth.ts b/src/lib/query-keys/auth.ts index f16bd68ab..197f1ebe2 100644 --- a/src/lib/query-keys/auth.ts +++ b/src/lib/query-keys/auth.ts @@ -56,6 +56,13 @@ export const authKeys = { */ mfaStatus: () => ["auth", "mfa", "status"] as const, + /** + * v1.23 — the "remember this device" trusted-device list behind + * `GET /api/auth/me/trusted-devices`. Revoking a device (or all of them) + * invalidates this key so the surface re-reads the live set. + */ + trustedDevices: () => ["auth", "trusted-devices"] 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 69c0cb621..6798c5fc3 100644 --- a/src/lib/validations/mfa.ts +++ b/src/lib/validations/mfa.ts @@ -42,6 +42,12 @@ export const mfaVerifySchema = z .describe("The opaque single-use ticket from the login response."), method: z.enum(["totp", "recovery"]).default("totp"), code: z.string().trim().min(6).max(32), + rememberDevice: z + .boolean() + .optional() + .describe( + "Opt in to trusting this device for 30 days — subsequent logins skip the second factor (the password is still required).", + ), }) .meta({ id: "MfaVerifyRequest", @@ -105,6 +111,12 @@ export const mfaWebauthnLoginVerifySchema = z mfaTicket: z.string().min(1), challengeId: z.string().min(1), credential: webauthnCredentialSchema, + rememberDevice: z + .boolean() + .optional() + .describe( + "Opt in to trusting this device for 30 days — subsequent logins skip the second factor (the password is still required).", + ), }) .meta({ id: "MfaWebauthnLoginVerifyRequest",