Skip to content

Commit 06cbdbb

Browse files
committed
fix(auth): invalidate email OTP on failed guess, not just a match (#2221)
1 parent 284ae24 commit 06cbdbb

2 files changed

Lines changed: 241 additions & 23 deletions

File tree

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import type { MySql2Database } from "drizzle-orm/mysql2";
2+
import { describe, expect, it } from "vitest";
3+
import { DrizzleAdapter } from "../../../../packages/database/auth/drizzle-adapter";
4+
5+
interface VerificationTokenRow {
6+
identifier: string;
7+
token: string;
8+
expires: Date;
9+
}
10+
11+
function createMockDb(initialRows: VerificationTokenRow[]) {
12+
let table = [...initialRows];
13+
let deletePredicate: unknown = null;
14+
15+
const db = {
16+
select: () => ({
17+
from: () => ({
18+
where: () => ({
19+
limit: async () => table.slice(0, 1),
20+
}),
21+
}),
22+
}),
23+
delete: () => ({
24+
where: (pred: unknown) => {
25+
deletePredicate = pred;
26+
const initialCount = table.length;
27+
table = table.filter(
28+
(row) =>
29+
!(
30+
row.identifier.toLowerCase() === "user@example.com" &&
31+
row.token === "123456"
32+
),
33+
);
34+
const rowsAffected = initialCount - table.length;
35+
return Promise.resolve({ rowsAffected });
36+
},
37+
}),
38+
transaction: async (cb: (tx: unknown) => Promise<unknown>) => cb(db),
39+
getTable: () => table,
40+
getDeletePredicate: () => deletePredicate,
41+
};
42+
43+
return db;
44+
}
45+
46+
describe("useVerificationToken", () => {
47+
it("burns the token on wrong guess and returns null", async () => {
48+
const mockDb = createMockDb([
49+
{
50+
identifier: "user@example.com",
51+
token: "123456",
52+
expires: new Date(Date.now() + 600000),
53+
},
54+
]);
55+
56+
const adapter = DrizzleAdapter(mockDb as unknown as MySql2Database);
57+
const result = await adapter.useVerificationToken?.({
58+
identifier: "USER@example.com",
59+
token: "999999",
60+
});
61+
62+
expect(result).toBeNull();
63+
expect(mockDb.getDeletePredicate()).not.toBeNull();
64+
expect(mockDb.getTable()).toHaveLength(0);
65+
});
66+
67+
it("returns token and invalidates it on correct guess", async () => {
68+
const mockDb = createMockDb([
69+
{
70+
identifier: "user@example.com",
71+
token: "123456",
72+
expires: new Date(Date.now() + 600000),
73+
},
74+
]);
75+
76+
const adapter = DrizzleAdapter(mockDb as unknown as MySql2Database);
77+
const result = await adapter.useVerificationToken?.({
78+
identifier: "USER@example.com",
79+
token: "123456",
80+
});
81+
82+
expect(result).not.toBeNull();
83+
expect(result?.identifier).toBe("user@example.com");
84+
expect(result?.token).toBe("123456");
85+
expect(mockDb.getDeletePredicate()).not.toBeNull();
86+
expect(mockDb.getTable()).toHaveLength(0);
87+
});
88+
89+
it("returns null if token does not exist", async () => {
90+
const mockDb = createMockDb([]);
91+
92+
const adapter = DrizzleAdapter(mockDb as unknown as MySql2Database);
93+
const result = await adapter.useVerificationToken?.({
94+
identifier: "nonexistent@example.com",
95+
token: "123456",
96+
});
97+
98+
expect(result).toBeNull();
99+
expect(mockDb.getDeletePredicate()).toBeNull();
100+
});
101+
102+
it("prevents race condition by checking rowsAffected on token consumption", async () => {
103+
let table = [
104+
{
105+
identifier: "user@example.com",
106+
token: "123456",
107+
expires: new Date(Date.now() + 600000),
108+
},
109+
];
110+
111+
const mockDb = {
112+
select: () => ({
113+
from: () => ({
114+
where: () => ({
115+
limit: async () => table.slice(0, 1),
116+
}),
117+
}),
118+
}),
119+
delete: () => ({
120+
where: () => {
121+
const initialCount = table.length;
122+
table = [];
123+
const rowsAffected = initialCount;
124+
return Promise.resolve({ rowsAffected });
125+
},
126+
}),
127+
transaction: async (cb: (tx: unknown) => Promise<unknown>) => cb(mockDb),
128+
} as unknown as MySql2Database;
129+
130+
const adapter = DrizzleAdapter(mockDb);
131+
132+
const firstResult = await adapter.useVerificationToken?.({
133+
identifier: "USER@example.com",
134+
token: "123456",
135+
});
136+
137+
expect(firstResult).not.toBeNull();
138+
expect(firstResult?.token).toBe("123456");
139+
140+
const secondResult = await adapter.useVerificationToken?.({
141+
identifier: "USER@example.com",
142+
token: "123456",
143+
});
144+
145+
expect(secondResult).toBeNull();
146+
});
147+
148+
it("deletes only the selected token instance and preserves replacement tokens for the same user", async () => {
149+
let table = [
150+
{
151+
identifier: "user@example.com",
152+
token: "123456",
153+
expires: new Date(Date.now() + 600000),
154+
},
155+
{
156+
identifier: "user@example.com",
157+
token: "replacement_token",
158+
expires: new Date(Date.now() + 600000),
159+
},
160+
];
161+
162+
const mockDb = {
163+
select: () => ({
164+
from: () => ({
165+
where: () => ({
166+
limit: async () => [table[0]],
167+
}),
168+
}),
169+
}),
170+
delete: () => ({
171+
where: () => {
172+
const initialCount = table.length;
173+
table = table.filter(
174+
(row) =>
175+
!(
176+
row.identifier === "user@example.com" && row.token === "123456"
177+
),
178+
);
179+
const rowsAffected = initialCount - table.length;
180+
return Promise.resolve({ rowsAffected });
181+
},
182+
}),
183+
transaction: async (cb: (tx: unknown) => Promise<unknown>) => cb(mockDb),
184+
} as unknown as MySql2Database;
185+
186+
const adapter = DrizzleAdapter(mockDb);
187+
const result = await adapter.useVerificationToken?.({
188+
identifier: "USER@example.com",
189+
token: "999999",
190+
});
191+
192+
expect(result).toBeNull();
193+
expect(table.some((r) => r.token === "123456")).toBe(false);
194+
expect(table.some((r) => r.token === "replacement_token")).toBe(true);
195+
});
196+
});

packages/database/auth/drizzle-adapter.ts

Lines changed: 45 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -510,31 +510,53 @@ export function DrizzleAdapter(
510510
return row;
511511
},
512512
async useVerificationToken({ identifier, token }) {
513-
const rows = await db
514-
.select()
515-
.from(verificationTokens)
516-
.where(eq(verificationTokens.token, token))
517-
.limit(1);
518-
const row = rows[0];
519-
if (!row) {
520-
console.warn("[useVerificationToken] No token found");
521-
return null;
522-
}
523513
const normalizedIdentifier = identifier?.toLowerCase() ?? "";
524-
const storedIdentifier = row.identifier?.toLowerCase() ?? "";
525-
if (normalizedIdentifier !== storedIdentifier) {
526-
console.warn("[useVerificationToken] Identifier mismatch");
527-
return null;
514+
515+
const execute = async (tx: typeof db) => {
516+
const rows = await tx
517+
.select()
518+
.from(verificationTokens)
519+
.where(eq(verificationTokens.identifier, normalizedIdentifier))
520+
.limit(1);
521+
const row = rows[0];
522+
if (!row) {
523+
console.warn("[useVerificationToken] No token found");
524+
return null;
525+
}
526+
const storedIdentifier = row.identifier?.toLowerCase() ?? "";
527+
528+
// Invalidate the specific token instance that was selected. This burns wrong guesses
529+
// while scoping deletion to both identifier AND row.token to protect newly issued replacement tokens.
530+
const result = await tx
531+
.delete(verificationTokens)
532+
.where(
533+
and(
534+
eq(verificationTokens.identifier, row.identifier),
535+
eq(verificationTokens.token, row.token),
536+
),
537+
);
538+
539+
// If database reports 0 rows affected, token was consumed or rotated concurrently
540+
const rowsAffected = (result as { rowsAffected?: number })?.rowsAffected;
541+
if (rowsAffected === 0) {
542+
console.warn(
543+
"[useVerificationToken] Token already consumed or invalid during deletion.",
544+
);
545+
return null;
546+
}
547+
548+
if (row.token !== token) {
549+
console.warn("[useVerificationToken] Token mismatch");
550+
return null;
551+
}
552+
553+
return { ...row, identifier: storedIdentifier };
554+
};
555+
556+
if (typeof db.transaction === "function") {
557+
return await db.transaction(async (tx) => execute(tx as unknown as typeof db));
528558
}
529-
await db
530-
.delete(verificationTokens)
531-
.where(
532-
and(
533-
eq(verificationTokens.token, token),
534-
eq(verificationTokens.identifier, row.identifier),
535-
),
536-
);
537-
return { ...row, identifier: storedIdentifier };
559+
return await execute(db);
538560
},
539561
};
540562
}

0 commit comments

Comments
 (0)