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

+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>
);
}