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",