feat(auth): allow trusting a device to skip the second factor

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).
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 21:49:39 +02:00
1 parent 45a49f3d6d
commit 135bb04f0d
27 files changed
+1277 -8

No files matched your search

+168
View File
@@ -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:
+14 -1
View File
@@ -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}"
}
}
},
+14 -1
View File
@@ -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}"
}
}
},
+14 -1
View File
@@ -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}"
}
}
},
+14 -1
View File
@@ -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}"
}
}
},
+14 -1
View File
@@ -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}"
}
}
},
+14 -1
View File
@@ -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}"
}
}
},
@@ -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;
+40
View File
@@ -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
@@ -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() },
@@ -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),
@@ -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();
});
});
+41
View File
@@ -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,
@@ -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 });
},
);
@@ -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 });
});
+18 -1
View File
@@ -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,
@@ -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,
}));
+14 -1
View File
@@ -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,
+24
View File
@@ -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<string | null>(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({
</Button>
)}
{/* 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 && (
<label
htmlFor={rememberId}
className="text-muted-foreground flex items-center gap-2 text-sm"
>
<Checkbox
id={rememberId}
checked={rememberDevice}
onCheckedChange={(v) => setRememberDevice(v === true)}
/>
{t("auth.mfa.rememberDevice")}
</label>
)}
{hasTotp && (
<button
type="button"
@@ -10,6 +10,7 @@ import { TotpCard } from "./totp-card";
import { SecurityKeysCard, type WebauthnKeyInfo } from "./security-keys-card";
import { PasskeyListSection } from "./passkey-list-section";
import { PasskeyUpgradeNudge } from "./passkey-upgrade-nudge";
import { TrustedDevicesCard } from "@/components/settings/trusted-devices-card";
interface MfaStatus {
totp: { enabled: boolean };
@@ -67,6 +68,8 @@ export function SecuritySection() {
<SecurityKeysCard keys={status.webauthn} />
<PasskeyListSection isAuthenticated={isAuthenticated} />
<TrustedDevicesCard isAuthenticated={isAuthenticated} />
</div>
);
}
@@ -0,0 +1,172 @@
"use client";
/**
* v1.23 — Settings → "Trusted devices" ("remember this device").
*
* Lists the browsers the user opted to trust at the second-factor step. A
* trusted device skips factor 2 for 30 days (the password is still required);
* this card makes them visible and revocable. The list is hidden entirely when
* empty so a user who never opted in sees no clutter.
*/
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiDelete } from "@/lib/api/api-fetch";
interface TrustedDeviceRow {
id: string;
label: string | null;
createdAt: string;
lastUsedAt: string;
expiresAt: string;
isCurrent: boolean;
}
export function TrustedDevicesCard({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [status, setStatus] = useState<string | null>(null);
const { data, isLoading, isError } = useQuery({
queryKey: queryKeys.trustedDevices(),
queryFn: () =>
apiGet<{ devices: TrustedDeviceRow[] }>("/api/auth/me/trusted-devices"),
enabled: isAuthenticated,
});
function invalidate() {
void queryClient.invalidateQueries({
queryKey: queryKeys.trustedDevices(),
});
void queryClient.invalidateQueries({
queryKey: queryKeys.securityActivity(),
});
}
const revokeOne = useMutation({
mutationFn: (id: string) => apiDelete(`/api/auth/me/trusted-devices/${id}`),
onSuccess: () => {
setStatus(null);
invalidate();
},
onError: () => setStatus(t("settings.security.trustedDevices.revokeError")),
});
const revokeAll = useMutation({
mutationFn: () =>
apiDelete<{ revoked: number }>("/api/auth/me/trusted-devices"),
onSuccess: (res) => {
setStatus(
t("settings.security.trustedDevices.revokeAllDone", {
count: res?.revoked ?? 0,
}),
);
invalidate();
},
onError: () => setStatus(t("settings.security.trustedDevices.revokeError")),
});
const devices = data?.devices ?? [];
// Hide the whole card when the user has no trusted devices — nothing to show
// and no opt-in is offered here (that happens at the second-factor step).
if (!isLoading && !isError && devices.length === 0) {
return null;
}
return (
<SettingsCard data-slot="settings-trusted-devices-card">
<SettingsCardHeader
icon={ShieldCheck}
title={t("settings.security.trustedDevices.title")}
className="mb-4"
/>
<div className="space-y-4 pl-7">
<p className="text-muted-foreground text-xs">
{t("settings.security.trustedDevices.description")}
</p>
{isLoading && (
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
)}
{isError && (
<p role="alert" className="text-destructive text-sm">
{t("settings.security.trustedDevices.loadError")}
</p>
)}
{devices.length > 0 && (
<ul className="divide-border divide-y">
{devices.map((d) => (
<li
key={d.id}
className="flex items-center justify-between gap-3 py-3"
data-slot="trusted-device-row"
>
<div className="min-w-0 space-y-0.5">
<p className="truncate text-sm font-medium">
{d.label ?? t("settings.security.trustedDevices.unnamed")}
{d.isCurrent && (
<span className="text-success ml-2 text-xs font-normal">
{t("settings.security.trustedDevices.current")}
</span>
)}
</p>
<p className="text-muted-foreground text-xs">
{t("settings.security.trustedDevices.expires", {
date: new Date(d.expiresAt).toLocaleDateString(),
})}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-9 shrink-0"
onClick={() => revokeOne.mutate(d.id)}
disabled={revokeOne.isPending}
>
{t("settings.security.trustedDevices.revoke")}
</Button>
</li>
))}
</ul>
)}
{devices.length > 0 && (
<div className="flex justify-end">
<Button
type="button"
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={() => revokeAll.mutate()}
disabled={revokeAll.isPending}
>
{revokeAll.isPending && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("settings.security.trustedDevices.revokeAll")}
</Button>
</div>
)}
{status && (
<p role="status" className="text-muted-foreground text-right text-sm">
{status}
</p>
)}
</div>
</SettingsCard>
);
}
@@ -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);
});
});
+8
View File
@@ -211,6 +211,10 @@ export async function destroyAllSessions(userId: string): Promise<void> {
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 };
}
+182
View File
@@ -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<boolean> {
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<string | null> {
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<TrustedDeviceInfo[]> {
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<boolean> {
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<number> {
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<void> {
const cookieStore = await cookies();
cookieStore.delete(TRUSTED_DEVICE_COOKIE);
}
+88
View File
@@ -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<ZodOpenApiObject["paths"]> = {
},
},
},
"/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"],
+7
View File
@@ -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,
/**
+12
View File
@@ -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",