From 3a05910815c75e9b1d1fb8e70fedfb774de21877 Mon Sep 17 00:00:00 2001 From: massmarketconsumer-arch Date: Sat, 12 Sep 2026 16:45:11 -0400 Subject: [PATCH 1/2] fix(auth): burn verification token on verification attempt to prevent brute force (#2221) --- .../unit/use-verification-token.test.ts | 165 ++++++++++++++++++ packages/database/auth/drizzle-adapter.ts | 43 +++-- 2 files changed, 196 insertions(+), 12 deletions(-) create mode 100644 apps/web/__tests__/unit/use-verification-token.test.ts diff --git a/apps/web/__tests__/unit/use-verification-token.test.ts b/apps/web/__tests__/unit/use-verification-token.test.ts new file mode 100644 index 0000000000..d5424f5c87 --- /dev/null +++ b/apps/web/__tests__/unit/use-verification-token.test.ts @@ -0,0 +1,165 @@ +import type { MySql2Database } from "drizzle-orm/mysql2"; +import { describe, expect, it, vi } from "vitest"; +import { DrizzleAdapter } from "../../../../packages/database/auth/drizzle-adapter"; + +describe("DrizzleAdapter.useVerificationToken", () => { + it("burns token immediately and returns null on wrong token guess", async () => { + const targetEmail = "victim@example.com"; + const realToken = "847291"; + const wrongGuess = "123456"; + const expires = new Date(Date.now() + 600_000); + + let deleted = false; + const mockDb = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn(() => + Promise.resolve([ + { + identifier: targetEmail, + token: realToken, + expires, + }, + ]), + ), + })), + })), + })), + delete: vi.fn(() => ({ + where: vi.fn(() => { + deleted = true; + return Promise.resolve([]); + }), + })), + } as unknown as MySql2Database; + + const adapter = DrizzleAdapter(mockDb, { getSsoIdentity: () => null }); + if (!adapter.useVerificationToken) + throw new Error("useVerificationToken not implemented"); + + const result = await adapter.useVerificationToken({ + identifier: targetEmail, + token: wrongGuess, + }); + + expect(result).toBeNull(); + expect(deleted).toBe(true); + expect(mockDb.delete).toHaveBeenCalledTimes(1); + }); + + it("burns token and returns null when token has expired", async () => { + const targetEmail = "user@example.com"; + const token = "654321"; + const expiredDate = new Date(Date.now() - 10_000); + + let deleted = false; + const mockDb = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn(() => + Promise.resolve([ + { + identifier: targetEmail, + token, + expires: expiredDate, + }, + ]), + ), + })), + })), + })), + delete: vi.fn(() => ({ + where: vi.fn(() => { + deleted = true; + return Promise.resolve([]); + }), + })), + } as unknown as MySql2Database; + + const adapter = DrizzleAdapter(mockDb, { getSsoIdentity: () => null }); + if (!adapter.useVerificationToken) + throw new Error("useVerificationToken not implemented"); + + const result = await adapter.useVerificationToken({ + identifier: targetEmail, + token: token, + }); + + expect(result).toBeNull(); + expect(deleted).toBe(true); + }); + + it("burns token and returns valid token object when token and identifier match", async () => { + const targetEmail = "User@Example.Com"; + const validToken = "998877"; + const expires = new Date(Date.now() + 600_000); + + let deleted = false; + const mockDb = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn(() => + Promise.resolve([ + { + identifier: targetEmail.toLowerCase(), + token: validToken, + expires, + }, + ]), + ), + })), + })), + })), + delete: vi.fn(() => ({ + where: vi.fn(() => { + deleted = true; + return Promise.resolve([]); + }), + })), + } as unknown as MySql2Database; + + const adapter = DrizzleAdapter(mockDb, { getSsoIdentity: () => null }); + if (!adapter.useVerificationToken) + throw new Error("useVerificationToken not implemented"); + + const result = await adapter.useVerificationToken({ + identifier: targetEmail, + token: validToken, + }); + + expect(result).not.toBeNull(); + expect(result?.token).toBe(validToken); + expect(result?.identifier).toBe(targetEmail.toLowerCase()); + expect(deleted).toBe(true); + }); + + it("returns null without deleting when no token exists for identifier", async () => { + const mockDb = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn(() => Promise.resolve([])), + })), + })), + })), + delete: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve([])), + })), + } as unknown as MySql2Database; + + const adapter = DrizzleAdapter(mockDb, { getSsoIdentity: () => null }); + if (!adapter.useVerificationToken) + throw new Error("useVerificationToken not implemented"); + + const result = await adapter.useVerificationToken({ + identifier: "nonexistent@example.com", + token: "111111", + }); + + expect(result).toBeNull(); + expect(mockDb.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/database/auth/drizzle-adapter.ts b/packages/database/auth/drizzle-adapter.ts index 810b6b75e3..09e654d7ff 100644 --- a/packages/database/auth/drizzle-adapter.ts +++ b/packages/database/auth/drizzle-adapter.ts @@ -456,30 +456,49 @@ export function DrizzleAdapter( return row; }, async useVerificationToken({ identifier, token }) { - const rows = await db - .select() - .from(verificationTokens) - .where(eq(verificationTokens.token, token)) - .limit(1); + const normalizedIdentifier = identifier?.toLowerCase() ?? ""; + const rows = normalizedIdentifier + ? await db + .select() + .from(verificationTokens) + .where(eq(verificationTokens.identifier, normalizedIdentifier)) + .limit(1) + : await db + .select() + .from(verificationTokens) + .where(eq(verificationTokens.token, token)) + .limit(1); const row = rows[0]; if (!row) { console.warn("[useVerificationToken] No token found"); return null; } - const normalizedIdentifier = identifier?.toLowerCase() ?? ""; - const storedIdentifier = row.identifier?.toLowerCase() ?? ""; - if (normalizedIdentifier !== storedIdentifier) { - console.warn("[useVerificationToken] Identifier mismatch"); - return null; - } + await db .delete(verificationTokens) .where( and( - eq(verificationTokens.token, token), eq(verificationTokens.identifier, row.identifier), + eq(verificationTokens.token, row.token), ), ); + + const storedIdentifier = row.identifier?.toLowerCase() ?? ""; + if (normalizedIdentifier && normalizedIdentifier !== storedIdentifier) { + console.warn("[useVerificationToken] Identifier mismatch"); + return null; + } + + if (row.expires.valueOf() < Date.now()) { + console.warn("[useVerificationToken] Token expired"); + return null; + } + + if (row.token !== token) { + console.warn("[useVerificationToken] Token mismatch"); + return null; + } + return { ...row, identifier: storedIdentifier }; }, }; From 6e9e36725deb1368fe20676f9c3a75ced338c0b7 Mon Sep 17 00:00:00 2001 From: massmarketconsumer-arch Date: Sat, 12 Sep 2026 16:50:21 -0400 Subject: [PATCH 2/2] fix(auth): make OTP verification and token deletion atomic with row locking --- .../unit/use-verification-token.test.ts | 233 +++++++++++------- packages/database/auth/drizzle-adapter.ts | 106 +++++--- 2 files changed, 207 insertions(+), 132 deletions(-) diff --git a/apps/web/__tests__/unit/use-verification-token.test.ts b/apps/web/__tests__/unit/use-verification-token.test.ts index d5424f5c87..9893956dda 100644 --- a/apps/web/__tests__/unit/use-verification-token.test.ts +++ b/apps/web/__tests__/unit/use-verification-token.test.ts @@ -2,6 +2,43 @@ import type { MySql2Database } from "drizzle-orm/mysql2"; import { describe, expect, it, vi } from "vitest"; import { DrizzleAdapter } from "../../../../packages/database/auth/drizzle-adapter"; +function createMockDb(initialRow?: { + identifier: string; + token: string; + expires: Date; +}) { + let row = initialRow; + let deleted = false; + + const createChain = () => { + const chain = { + for: vi.fn(() => chain), + limit: vi.fn(() => Promise.resolve(row ? [row] : [])), + }; + return chain; + }; + + const db = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => createChain()), + })), + })), + delete: vi.fn(() => ({ + where: vi.fn(() => { + deleted = true; + row = undefined; + return Promise.resolve([]); + }), + })), + } as unknown as MySql2Database; + + return { + db, + wasDeleted: () => deleted, + }; +} + describe("DrizzleAdapter.useVerificationToken", () => { it("burns token immediately and returns null on wrong token guess", async () => { const targetEmail = "victim@example.com"; @@ -9,32 +46,13 @@ describe("DrizzleAdapter.useVerificationToken", () => { const wrongGuess = "123456"; const expires = new Date(Date.now() + 600_000); - let deleted = false; - const mockDb = { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: vi.fn(() => - Promise.resolve([ - { - identifier: targetEmail, - token: realToken, - expires, - }, - ]), - ), - })), - })), - })), - delete: vi.fn(() => ({ - where: vi.fn(() => { - deleted = true; - return Promise.resolve([]); - }), - })), - } as unknown as MySql2Database; + const mock = createMockDb({ + identifier: targetEmail, + token: realToken, + expires, + }); - const adapter = DrizzleAdapter(mockDb, { getSsoIdentity: () => null }); + const adapter = DrizzleAdapter(mock.db, { getSsoIdentity: () => null }); if (!adapter.useVerificationToken) throw new Error("useVerificationToken not implemented"); @@ -44,8 +62,7 @@ describe("DrizzleAdapter.useVerificationToken", () => { }); expect(result).toBeNull(); - expect(deleted).toBe(true); - expect(mockDb.delete).toHaveBeenCalledTimes(1); + expect(mock.wasDeleted()).toBe(true); }); it("burns token and returns null when token has expired", async () => { @@ -53,42 +70,23 @@ describe("DrizzleAdapter.useVerificationToken", () => { const token = "654321"; const expiredDate = new Date(Date.now() - 10_000); - let deleted = false; - const mockDb = { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: vi.fn(() => - Promise.resolve([ - { - identifier: targetEmail, - token, - expires: expiredDate, - }, - ]), - ), - })), - })), - })), - delete: vi.fn(() => ({ - where: vi.fn(() => { - deleted = true; - return Promise.resolve([]); - }), - })), - } as unknown as MySql2Database; + const mock = createMockDb({ + identifier: targetEmail, + token, + expires: expiredDate, + }); - const adapter = DrizzleAdapter(mockDb, { getSsoIdentity: () => null }); + const adapter = DrizzleAdapter(mock.db, { getSsoIdentity: () => null }); if (!adapter.useVerificationToken) throw new Error("useVerificationToken not implemented"); const result = await adapter.useVerificationToken({ identifier: targetEmail, - token: token, + token, }); expect(result).toBeNull(); - expect(deleted).toBe(true); + expect(mock.wasDeleted()).toBe(true); }); it("burns token and returns valid token object when token and identifier match", async () => { @@ -96,32 +94,13 @@ describe("DrizzleAdapter.useVerificationToken", () => { const validToken = "998877"; const expires = new Date(Date.now() + 600_000); - let deleted = false; - const mockDb = { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: vi.fn(() => - Promise.resolve([ - { - identifier: targetEmail.toLowerCase(), - token: validToken, - expires, - }, - ]), - ), - })), - })), - })), - delete: vi.fn(() => ({ - where: vi.fn(() => { - deleted = true; - return Promise.resolve([]); - }), - })), - } as unknown as MySql2Database; + const mock = createMockDb({ + identifier: targetEmail.toLowerCase(), + token: validToken, + expires, + }); - const adapter = DrizzleAdapter(mockDb, { getSsoIdentity: () => null }); + const adapter = DrizzleAdapter(mock.db, { getSsoIdentity: () => null }); if (!adapter.useVerificationToken) throw new Error("useVerificationToken not implemented"); @@ -133,24 +112,13 @@ describe("DrizzleAdapter.useVerificationToken", () => { expect(result).not.toBeNull(); expect(result?.token).toBe(validToken); expect(result?.identifier).toBe(targetEmail.toLowerCase()); - expect(deleted).toBe(true); + expect(mock.wasDeleted()).toBe(true); }); it("returns null without deleting when no token exists for identifier", async () => { - const mockDb = { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: vi.fn(() => Promise.resolve([])), - })), - })), - })), - delete: vi.fn(() => ({ - where: vi.fn(() => Promise.resolve([])), - })), - } as unknown as MySql2Database; + const mock = createMockDb(undefined); - const adapter = DrizzleAdapter(mockDb, { getSsoIdentity: () => null }); + const adapter = DrizzleAdapter(mock.db, { getSsoIdentity: () => null }); if (!adapter.useVerificationToken) throw new Error("useVerificationToken not implemented"); @@ -160,6 +128,83 @@ describe("DrizzleAdapter.useVerificationToken", () => { }); expect(result).toBeNull(); - expect(mockDb.delete).not.toHaveBeenCalled(); + expect(mock.wasDeleted()).toBe(false); + }); + + it("ensures atomic single consumption under concurrent verification attempts", async () => { + const targetEmail = "concurrent@example.com"; + const validToken = "554433"; + const expires = new Date(Date.now() + 600_000); + + let tokenRecord: + | { + identifier: string; + token: string; + expires: Date; + } + | undefined = { + identifier: targetEmail, + token: validToken, + expires, + }; + + let lock = Promise.resolve(); + + const mockDb = { + transaction: vi.fn( + async (cb: (tx: MySql2Database) => Promise) => { + const currentLock = lock; + let releaseLock: () => void = () => {}; + lock = new Promise((resolve) => { + releaseLock = resolve; + }); + await currentLock; + try { + const tx = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + for: vi.fn(() => ({ + limit: vi.fn(() => + Promise.resolve(tokenRecord ? [tokenRecord] : []), + ), + })), + })), + })), + })), + delete: vi.fn(() => ({ + where: vi.fn(() => { + tokenRecord = undefined; + return Promise.resolve([]); + }), + })), + } as unknown as MySql2Database; + return await cb(tx); + } finally { + releaseLock(); + } + }, + ), + } as unknown as MySql2Database; + + const adapter = DrizzleAdapter(mockDb, { getSsoIdentity: () => null }); + if (!adapter.useVerificationToken) + throw new Error("useVerificationToken not implemented"); + + const [result1, result2] = await Promise.all([ + adapter.useVerificationToken({ + identifier: targetEmail, + token: validToken, + }), + adapter.useVerificationToken({ + identifier: targetEmail, + token: validToken, + }), + ]); + + const successCount = [result1, result2].filter((r) => r !== null).length; + const nullCount = [result1, result2].filter((r) => r === null).length; + expect(successCount).toBe(1); + expect(nullCount).toBe(1); }); }); diff --git a/packages/database/auth/drizzle-adapter.ts b/packages/database/auth/drizzle-adapter.ts index 09e654d7ff..090ba63402 100644 --- a/packages/database/auth/drizzle-adapter.ts +++ b/packages/database/auth/drizzle-adapter.ts @@ -457,49 +457,79 @@ export function DrizzleAdapter( }, async useVerificationToken({ identifier, token }) { const normalizedIdentifier = identifier?.toLowerCase() ?? ""; - const rows = normalizedIdentifier - ? await db - .select() - .from(verificationTokens) - .where(eq(verificationTokens.identifier, normalizedIdentifier)) - .limit(1) - : await db - .select() - .from(verificationTokens) - .where(eq(verificationTokens.token, token)) - .limit(1); - const row = rows[0]; - if (!row) { - console.warn("[useVerificationToken] No token found"); - return null; - } + const runVerification = async (tx: MySql2Database) => { + const builder = normalizedIdentifier + ? tx + .select() + .from(verificationTokens) + .where(eq(verificationTokens.identifier, normalizedIdentifier)) + : tx + .select() + .from(verificationTokens) + .where(eq(verificationTokens.token, token)); + + const lockedQuery = + typeof (builder as unknown as { for: unknown }).for === "function" + ? ( + builder as unknown as { + for: (mode: string) => unknown; + } + ).for("update") + : builder; + + const limitedQuery = + typeof (lockedQuery as unknown as { limit: unknown }).limit === + "function" + ? ( + lockedQuery as unknown as { + limit: ( + n: number, + ) => Promise>; + } + ).limit(1) + : lockedQuery; + + const rows = await (limitedQuery as Promise< + Array + >); + + const row = rows[0]; + if (!row) { + console.warn("[useVerificationToken] No token found"); + return null; + } - await db - .delete(verificationTokens) - .where( - and( - eq(verificationTokens.identifier, row.identifier), - eq(verificationTokens.token, row.token), - ), - ); + await tx + .delete(verificationTokens) + .where( + and( + eq(verificationTokens.identifier, row.identifier), + eq(verificationTokens.token, row.token), + ), + ); - const storedIdentifier = row.identifier?.toLowerCase() ?? ""; - if (normalizedIdentifier && normalizedIdentifier !== storedIdentifier) { - console.warn("[useVerificationToken] Identifier mismatch"); - return null; - } + const storedIdentifier = row.identifier?.toLowerCase() ?? ""; + if (normalizedIdentifier && normalizedIdentifier !== storedIdentifier) { + console.warn("[useVerificationToken] Identifier mismatch"); + return null; + } - if (row.expires.valueOf() < Date.now()) { - console.warn("[useVerificationToken] Token expired"); - return null; - } + if (row.expires.valueOf() < Date.now()) { + console.warn("[useVerificationToken] Token expired"); + return null; + } - if (row.token !== token) { - console.warn("[useVerificationToken] Token mismatch"); - return null; - } + if (row.token !== token) { + console.warn("[useVerificationToken] Token mismatch"); + return null; + } + + return { ...row, identifier: storedIdentifier }; + }; - return { ...row, identifier: storedIdentifier }; + return db.transaction + ? await db.transaction(runVerification) + : await runVerification(db); }, }; }