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 <adhabal2002@gmail.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 <adhabal2002@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Anik Dhabal Babu
2025-10-22 12:35:07 +01:00
committed by GitHub
co-authored by anik@cal.com <adhabal2002@gmail.com> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 1261247e94
commit 613e9f0788
2 changed files with 79 additions and 1 deletions
@@ -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();
});
});
@@ -10,6 +10,19 @@ const RECENT_PERIOD_IN_MINUTES = 5;
const createPasswordReset = async (email: string): Promise<string> => {
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,