Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -931,8 +931,14 @@ export async function accountForResetToken(token: string): Promise<string | null
if (!token) return null;
await ensureSchema();
const res = await db().execute({
sql: `SELECT id FROM accounts WHERE reset_token = ? AND reset_expires > 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;
}
Expand Down
29 changes: 29 additions & 0 deletions tests/reset-token-expiry.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});