Skip to content

Commit 40adcaa

Browse files
Tomasz NuriTomasz Nuri
authored andcommitted
fix(auth): scope OTP invalidation to selected token instance and verify race condition (#2221)
1 parent 8d4b5e9 commit 40adcaa

2 files changed

Lines changed: 130 additions & 1 deletion

File tree

apps/web/__tests__/unit/verification-token.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,120 @@ describe("useVerificationToken", () => {
103103
expect(result).toBeNull();
104104
expect(deleted).toBe(false);
105105
});
106+
107+
it("prevents race condition by checking rowsAffected on token consumption", async () => {
108+
let deletePredicate1: unknown = null;
109+
const mockDb1 = {
110+
select: () => ({
111+
from: () => ({
112+
where: () => ({
113+
limit: async () => [
114+
{
115+
identifier: "user@example.com",
116+
token: "123456",
117+
expires: new Date(Date.now() + 600000),
118+
},
119+
],
120+
}),
121+
}),
122+
}),
123+
delete: () => ({
124+
where: (pred: unknown) => {
125+
deletePredicate1 = pred;
126+
return Promise.resolve({ rowsAffected: 1 });
127+
},
128+
}),
129+
} as unknown as MySql2Database;
130+
131+
const adapter1 = DrizzleAdapter(mockDb1);
132+
133+
let deletePredicate2: unknown = null;
134+
const mockDb2 = {
135+
select: () => ({
136+
from: () => ({
137+
where: () => ({
138+
limit: async () => [
139+
{
140+
identifier: "user@example.com",
141+
token: "123456",
142+
expires: new Date(Date.now() + 600000),
143+
},
144+
],
145+
}),
146+
}),
147+
}),
148+
delete: () => ({
149+
where: (pred: unknown) => {
150+
deletePredicate2 = pred;
151+
return Promise.resolve({ rowsAffected: 0 });
152+
},
153+
}),
154+
} as unknown as MySql2Database;
155+
156+
const adapter2 = DrizzleAdapter(mockDb2);
157+
158+
const firstResult = await adapter1.useVerificationToken?.({
159+
identifier: "USER@example.com",
160+
token: "123456",
161+
});
162+
163+
expect(firstResult).not.toBeNull();
164+
expect(deletePredicate1).not.toBeNull();
165+
166+
const secondResult = await adapter2.useVerificationToken?.({
167+
identifier: "USER@example.com",
168+
token: "123456",
169+
});
170+
171+
expect(secondResult).toBeNull();
172+
expect(deletePredicate2).not.toBeNull();
173+
});
174+
175+
it("deletes only the selected token instance and preserves replacement tokens for the same user", async () => {
176+
let table = [
177+
{
178+
identifier: "user@example.com",
179+
token: "123456",
180+
expires: new Date(Date.now() + 600000),
181+
},
182+
{
183+
identifier: "user@example.com",
184+
token: "replacement_token",
185+
expires: new Date(Date.now() + 600000),
186+
},
187+
];
188+
189+
const mockDb = {
190+
select: () => ({
191+
from: () => ({
192+
where: () => ({
193+
limit: async () => [table[0]],
194+
}),
195+
}),
196+
}),
197+
delete: () => ({
198+
where: () => {
199+
const initialCount = table.length;
200+
table = table.filter(
201+
(row) =>
202+
!(
203+
row.identifier === "user@example.com" && row.token === "123456"
204+
),
205+
);
206+
const rowsAffected = initialCount - table.length;
207+
return Promise.resolve({ rowsAffected });
208+
},
209+
}),
210+
} as unknown as MySql2Database;
211+
212+
const adapter = DrizzleAdapter(mockDb);
213+
const result = await adapter.useVerificationToken?.({
214+
identifier: "USER@example.com",
215+
token: "999999",
216+
});
217+
218+
expect(result).toBeNull();
219+
expect(table.some((r) => r.token === "123456")).toBe(false);
220+
expect(table.some((r) => r.token === "replacement_token")).toBe(true);
221+
});
106222
});

packages/database/auth/drizzle-adapter.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,10 @@ export function DrizzleAdapter(
522522
return null;
523523
}
524524
const storedIdentifier = row.identifier?.toLowerCase() ?? "";
525-
await db
525+
526+
// Invalidate the specific token instance that was selected. This burns wrong guesses
527+
// while scoping deletion to both identifier AND row.token to protect newly issued replacement tokens.
528+
const result = await db
526529
.delete(verificationTokens)
527530
.where(
528531
and(
@@ -531,10 +534,20 @@ export function DrizzleAdapter(
531534
),
532535
);
533536

537+
// If database reports 0 rows affected, token was consumed or rotated concurrently
538+
const rowsAffected = (result as { rowsAffected?: number })?.rowsAffected;
539+
if (rowsAffected === 0) {
540+
console.warn(
541+
"[useVerificationToken] Token already consumed or invalid during deletion.",
542+
);
543+
return null;
544+
}
545+
534546
if (row.token !== token) {
535547
console.warn("[useVerificationToken] Token mismatch");
536548
return null;
537549
}
550+
538551
return { ...row, identifier: storedIdentifier };
539552
},
540553
};

0 commit comments

Comments
 (0)