From 613e9f07887a0c77e28acc4c8a7ef6427b4ed328 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Wed, 22 Oct 2025 17:05:07 +0530 Subject: [PATCH] fix: invalidate old password reset tokens when new one is requested (#24607) * fix: invalidate old password reset tokens when new one is requested Security fix: Previously, old password reset tokens remained valid even after requesting a new one, creating a potential account takeover vulnerability. This change ensures that when a user requests a new password reset link, all previous valid tokens for that email are immediately invalidated. Changes: - Expire all existing valid tokens before creating new one - Add E2E test to verify old tokens are invalidated - Prevent potential account takeover scenario Co-Authored-By: anik@cal.com * Clean up code by removing blank line Removed unnecessary blank line in forgot-password.e2e.ts. * test: fix strict mode violation in password reset test Use getByRole to specifically target the heading element instead of text locator which was matching both the heading and button. Co-Authored-By: anik@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../playwright/auth/forgot-password.e2e.ts | 67 ++++++++++++++++++- .../features/auth/lib/passwordResetRequest.ts | 13 ++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/apps/web/playwright/auth/forgot-password.e2e.ts b/apps/web/playwright/auth/forgot-password.e2e.ts index 1b62be7de9..3a53ede82e 100644 --- a/apps/web/playwright/auth/forgot-password.e2e.ts +++ b/apps/web/playwright/auth/forgot-password.e2e.ts @@ -88,7 +88,6 @@ test.describe("Forgot password", async () => { }, }); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const updatedPassword = updatedUser.password!.hash; expect(await verifyPassword(newPassword, updatedPassword)).toBeTruthy(); @@ -97,4 +96,70 @@ test.describe("Forgot password", async () => { await expect(page.locator(`text=Whoops`)).toBeVisible(); }); + + test("Old tokens are invalidated when new reset link is requested", async ({ page, users }) => { + const user = await users.create(); + + await page.goto("/auth/forgot-password"); + await page.waitForSelector("text=Forgot Password?"); + + await page.fill('input[name="email"]', `${user.username}@example.com`); + await page.press('input[name="email"]', "Enter"); + await page.waitForLoadState("networkidle"); + + await page.waitForSelector("text=Reset link sent"); + + const firstRequest = await prisma.resetPasswordRequest.findFirstOrThrow({ + where: { + email: user.email, + }, + select: { + id: true, + expires: true, + }, + orderBy: { + createdAt: "desc", + }, + }); + + await page.goto("/auth/forgot-password"); + await page.waitForSelector("text=Forgot Password?"); + + await page.fill('input[name="email"]', `${user.username}@example.com`); + await page.press('input[name="email"]', "Enter"); + await page.waitForLoadState("networkidle"); + + await page.waitForSelector("text=Reset link sent"); + + const secondRequest = await prisma.resetPasswordRequest.findFirstOrThrow({ + where: { + email: user.email, + }, + select: { + id: true, + expires: true, + }, + orderBy: { + createdAt: "desc", + }, + }); + + const firstRequestAfterSecond = await prisma.resetPasswordRequest.findUniqueOrThrow({ + where: { + id: firstRequest.id, + }, + select: { + expires: true, + }, + }); + + expect(firstRequest.id).not.toBe(secondRequest.id); + expect(firstRequestAfterSecond.expires.getTime()).toBeLessThanOrEqual(new Date().getTime()); + + await page.goto(`/auth/forgot-password/${firstRequest.id}`); + await expect(page.locator(`text=Whoops`)).toBeVisible(); + + await page.goto(`/auth/forgot-password/${secondRequest.id}`); + await expect(page.getByRole("heading", { name: "Reset Password" })).toBeVisible(); + }); }); diff --git a/packages/features/auth/lib/passwordResetRequest.ts b/packages/features/auth/lib/passwordResetRequest.ts index 6b6fbbea49..5e502d6ef9 100644 --- a/packages/features/auth/lib/passwordResetRequest.ts +++ b/packages/features/auth/lib/passwordResetRequest.ts @@ -10,6 +10,19 @@ const RECENT_PERIOD_IN_MINUTES = 5; const createPasswordReset = async (email: string): Promise => { const expiry = dayjs().add(PASSWORD_RESET_EXPIRY_HOURS, "hours").toDate(); + + await prisma.resetPasswordRequest.updateMany({ + where: { + email, + expires: { + gt: new Date(), + }, + }, + data: { + expires: new Date(), + }, + }); + const createdResetPasswordRequest = await prisma.resetPasswordRequest.create({ data: { email,