From 0ccc105671e08d17d7317b2fdcf1b73f8988975a Mon Sep 17 00:00:00 2001 From: clawedassistant26 <307253840+clawedassistant26@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:24:52 +0000 Subject: [PATCH] fix(auth): reject password-reset tokens after they expire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit accountForResetToken compared reset_expires against SQLite's datetime('now') as a string. reset_expires is written by setResetToken as an ISO-8601 timestamp (Date#toISOString, e.g. "2026-07-20T20:53:43.696Z"), whereas datetime('now') returns a space-separated form ("2026-07-20 21:23:43"). Lexically the ISO "T" separator (0x54) always sorts after the space (0x20), so any token whose expiry falls on the current UTC day read as still valid — defeating the intended 1-hour expiry and keeping reset links usable for up to ~24h. Compare against an ISO "now" (new Date().toISOString()) so it is a like-for-like comparison; ISO-8601 UTC strings sort chronologically. Adds a regression test (in-memory libSQL) asserting an expired token is rejected while a future one is accepted. Co-Authored-By: Claude Opus 4.8 --- lib/db.ts | 10 ++++++++-- tests/reset-token-expiry.test.mjs | 29 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 tests/reset-token-expiry.test.mjs diff --git a/lib/db.ts b/lib/db.ts index aa8c32a..5c3df38 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -931,8 +931,14 @@ export async function accountForResetToken(token: string): Promise datetime('now')`, - args: [token], + // reset_expires is stored as an ISO-8601 string (Date#toISOString, e.g. + // "2026-07-20T20:53:43.696Z"); compare it against an ISO "now" so the check + // is a like-for-like lexical comparison. Comparing against SQLite's + // datetime('now') ("2026-07-20 21:23:43") instead makes the "T" separator + // (0x54) always sort after the space (0x20), so an expired token would read + // as still valid for the rest of the UTC day. + sql: `SELECT id FROM accounts WHERE reset_token = ? AND reset_expires > ?`, + args: [token, new Date().toISOString()], }); return res.rows[0] ? String(res.rows[0].id) : null; } diff --git a/tests/reset-token-expiry.test.mjs b/tests/reset-token-expiry.test.mjs new file mode 100644 index 0000000..9e377b1 --- /dev/null +++ b/tests/reset-token-expiry.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// In-memory libSQL so the token-expiry query runs against a real database. +process.env.TURSO_DATABASE_URL = ":memory:"; +const { ensureSchema, createAccount, setResetToken, accountForResetToken } = + await import("../lib/db.ts"); + +test("reset tokens are rejected once expired", async () => { + await ensureSchema(); + const acct = await createAccount({ + email: "reset@example.com", + passwordHash: "h", + passwordSalt: "s", + domain: "example.com", + }); + + // Valid (future) token is accepted. + const future = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + await setResetToken("reset@example.com", "valid-token", future); + assert.equal(await accountForResetToken("valid-token"), acct.id); + + // Token that expired 30 minutes ago (same UTC day) must be rejected. A string + // comparison against SQLite datetime('now') would wrongly accept it, because + // the ISO "T" separator always sorts after datetime('now')'s space. + const expired = new Date(Date.now() - 30 * 60 * 1000).toISOString(); + await setResetToken("reset@example.com", "expired-token", expired); + assert.equal(await accountForResetToken("expired-token"), null); +});