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

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