fix(security): keep the first sign-in from alerting on a fresh account

Record the very first device a user is ever seen on silently. The
new-device sign-in alert only carries meaning once there is an
established device to contrast against, so an empty device ledger
establishes the baseline without firing a notification; alerts begin
with the second distinct device. Registration's silent first-device
behaviour is unchanged.

Also extend the destructive-action step-up to treat a registered
security key as an active second factor, matching requireFreshMfa, so a
key-only account cannot delete or reset without a fresh factor.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:53:48 +02:00
1 parent b271a39fe4
commit 45a49f3d6d
3 files changed
+47 -1

No files matched your search

@@ -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({
@@ -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",
+12 -1
View File
@@ -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,