diff --git a/src/lib/__tests__/require-fresh-mfa-if-enrolled.test.ts b/src/lib/__tests__/require-fresh-mfa-if-enrolled.test.ts index a05a523a2..d5b6af276 100644 --- a/src/lib/__tests__/require-fresh-mfa-if-enrolled.test.ts +++ b/src/lib/__tests__/require-fresh-mfa-if-enrolled.test.ts @@ -7,6 +7,7 @@ vi.mock("@/lib/db", () => ({ session: { findUnique: vi.fn() }, apiToken: { findUnique: vi.fn(), update: vi.fn() }, user: { findUnique: vi.fn() }, + webauthnMfaCredential: { count: vi.fn() }, }, })); @@ -57,6 +58,9 @@ beforeEach(() => { // resetAllMocks wipes the factory mockResolvedValue; auditLog is awaited // via `.catch(...)` in the Bearer path, so it must return a thenable. vi.mocked(auditLog).mockResolvedValue(undefined); + // Default: no registered security key, so a TOTP-less account is genuinely + // unenrolled and passes straight through. + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never); }); function mockCookieSession(user: unknown) { @@ -74,6 +78,17 @@ describe("requireFreshMfaIfEnrolled", () => { expect(prisma.session.findUnique).not.toHaveBeenCalled(); }); + it("gates a security-key-only user (no TOTP) on step-up too", async () => { + mockCookieSession(PLAIN_USER); + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never); + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + mfaVerifiedAt: null, + } as never); + await expect( + requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS), + ).rejects.toBeInstanceOf(StepUpRequiredError); + }); + it("passes an MFA user with a fresh verification", async () => { mockCookieSession(MFA_USER); vi.mocked(prisma.session.findUnique).mockResolvedValue({ diff --git a/src/lib/auth/__tests__/login-alert.test.ts b/src/lib/auth/__tests__/login-alert.test.ts index f64d43aa8..a4ab165a9 100644 --- a/src/lib/auth/__tests__/login-alert.test.ts +++ b/src/lib/auth/__tests__/login-alert.test.ts @@ -6,6 +6,7 @@ vi.mock("@/lib/db", () => ({ findUnique: vi.fn(), update: vi.fn(), create: vi.fn(), + count: vi.fn(), }, user: { findUnique: vi.fn() }, }, @@ -42,6 +43,9 @@ beforeEach(() => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ locale: "en", } as never); + // Default: the user already has at least one established device, so a new + // sighting is a genuine second-or-later device and may alert. + vi.mocked(prisma.userKnownDevice.count).mockResolvedValue(1 as never); }); describe("recordSignInDevice — new-device dedupe", () => { @@ -63,6 +67,22 @@ describe("recordSignInDevice — new-device dedupe", () => { expect(payload.userId).toBe("u1"); }); + it("first-ever device on an empty ledger records silently (baseline)", async () => { + vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue(null); + vi.mocked(prisma.userKnownDevice.count).mockResolvedValue(0 as never); + vi.mocked(prisma.userKnownDevice.create).mockResolvedValue({} as never); + + const res = await recordSignInDevice({ + userId: "u1", + ip: "203.0.113.9", + userAgent: UA, + }); + + expect(res).toEqual({ known: false, alerted: false }); + expect(prisma.userKnownDevice.create).toHaveBeenCalledTimes(1); + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + it("second sighting of the same device is silent (no alert)", async () => { vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue({ id: "kd-1", diff --git a/src/lib/auth/login-alert.ts b/src/lib/auth/login-alert.ts index 4da185087..1cad93740 100644 --- a/src/lib/auth/login-alert.ts +++ b/src/lib/auth/login-alert.ts @@ -87,6 +87,17 @@ export async function recordSignInDevice(params: { return { known: true, alerted: false }; } + // First sighting of THIS device. If the user has no known devices at all + // the ledger is empty — this is the account's baseline (the first login + // after the feature shipped, or an account whose registration predates the + // ledger). Establish it silently: a "new device" alert only means anything + // once there is at least one established device to contrast against, so we + // never alert on the very first device a user is ever seen on. + const knownDeviceCount = await prisma.userKnownDevice.count({ + where: { userId }, + }); + const isBaseline = knownDeviceCount === 0; + // First sighting. Insert; if a concurrent first-login races us to the // unique (userId, deviceHash) index, swallow it and treat as already known // so we never double-alert. @@ -98,7 +109,7 @@ export async function recordSignInDevice(params: { return { known: true, alerted: false }; } - if (!alertOnNew) return { known: false, alerted: false }; + if (!alertOnNew || isBaseline) return { known: false, alerted: false }; await auditLog("auth.login.new_device", { userId,