From fb246e2e55bbce07e1384ac124c0aa7eecc8daac Mon Sep 17 00:00:00 2001 From: "Vaka.pro" Date: Sun, 24 May 2026 14:27:22 +0300 Subject: [PATCH] fix: harden authentication security --- .env.example | 3 +- backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/app.ts | 51 +++++++------ backend/src/authService.ts | 78 ++++++++++++++++---- backend/src/config.ts | 26 ++++++- backend/src/db.ts | 109 ++++++++++++++++++++++++++- backend/src/index.ts | 2 + backend/test/app.test.ts | 147 +++++++++++++++++++++++++++++++++++-- 9 files changed, 371 insertions(+), 51 deletions(-) diff --git a/.env.example b/.env.example index 246443b..5f8b4d5 100644 --- a/.env.example +++ b/.env.example @@ -38,10 +38,11 @@ SESSION_SECRET=replace_with_32plus_char_random_secret # SESSION_COOKIE_NAME=__Host-sid # SESSION_COOKIE_SECURE=true # SESSION_TTL_DAYS=30 +# AUTH_CLEANUP_INTERVAL_HOURS=24 # ─── Cloudflare Turnstile ──────────────────────────────────── TURNSTILE_SECRET_KEY=replace_with_turnstile_secret -# Local tests/dev only, never production: +# Local tests/dev only, rejected in production: # TURNSTILE_BYPASS_TOKEN=mock-turnstile-token # ─── SMTP email ────────────────────────────────────────────── diff --git a/backend/package-lock.json b/backend/package-lock.json index fa231ce..828ad65 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "calendar-run-backend", - "version": "1.4.0", + "version": "1.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "calendar-run-backend", - "version": "1.4.0", + "version": "1.4.1", "dependencies": { "argon2": "^0.44.0", "cookie-parser": "^1.4.7", diff --git a/backend/package.json b/backend/package.json index e94dcef..a1b2e48 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "calendar-run-backend", - "version": "1.4.0", + "version": "1.4.1", "private": true, "scripts": { "build": "tsc", diff --git a/backend/src/app.ts b/backend/src/app.ts index c3e25af..316b398 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -8,32 +8,37 @@ import authRouter from "./routes/auth"; import healthRouter from "./routes/health"; import racesRouter from "./routes/races"; +const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com"; + +export function buildHelmetOptions(securityProfile: string) { + return { + contentSecurityPolicy: + securityProfile === "production" + ? { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", TURNSTILE_ORIGIN], + styleSrc: ["'self'"], + imgSrc: ["'self'", "data:", "https:"], + connectSrc: ["'self'", TURNSTILE_ORIGIN], + frameSrc: [TURNSTILE_ORIGIN], + objectSrc: ["'none'"], + frameAncestors: ["'none'"], + }, + } + : false, + hsts: + securityProfile === "production" + ? { maxAge: 31_536_000, includeSubDomains: true } + : false, + referrerPolicy: { policy: "strict-origin-when-cross-origin" as const }, + }; +} + export function createApp(): express.Express { const app = express(); - app.use( - helmet({ - contentSecurityPolicy: - config.securityProfile === "production" - ? { - directives: { - defaultSrc: ["'self'"], - scriptSrc: ["'self'"], - styleSrc: ["'self'"], - imgSrc: ["'self'", "data:", "https:"], - connectSrc: ["'self'"], - objectSrc: ["'none'"], - frameAncestors: ["'none'"], - }, - } - : false, - hsts: - config.securityProfile === "production" - ? { maxAge: 31_536_000, includeSubDomains: true } - : false, - referrerPolicy: { policy: "strict-origin-when-cross-origin" }, - }), - ); + app.use(helmet(buildHelmetOptions(config.securityProfile))); app.use( cors({ origin: config.corsOrigin, diff --git a/backend/src/authService.ts b/backend/src/authService.ts index 581a9e8..c1630e9 100644 --- a/backend/src/authService.ts +++ b/backend/src/authService.ts @@ -58,6 +58,10 @@ function appUrl(path: string, token: string): string { return url.toString(); } +function isUniqueViolation(error: unknown): boolean { + return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "23505"; +} + export async function findUserByEmail(email: string): Promise { const normalized = normalizeEmail(email); const { rows } = await pool.query( @@ -114,20 +118,25 @@ export async function sendResetEmail(email: string, token: string): Promise { const normalized = normalizeEmail(email); const passwordHash = await hashPassword(password); - const existing = await findUserByEmail(normalized); - if (existing) { - securityLog("register.existing", { emailHash: anonymizeEmail(normalized) }); - return; - } const client = await pool.connect(); try { await client.query("BEGIN"); - const { rows } = await client.query( - `INSERT INTO users (email, password_hash) - VALUES ($1, $2) - RETURNING id, email, password_hash, email_verified_at`, - [normalized, passwordHash], - ); + let rows: UserRow[]; + try { + ({ rows } = await client.query( + `INSERT INTO users (email, password_hash) + VALUES ($1, $2) + RETURNING id, email, password_hash, email_verified_at`, + [normalized, passwordHash], + )); + } catch (error) { + if (isUniqueViolation(error)) { + await client.query("ROLLBACK"); + securityLog("register.existing", { emailHash: anonymizeEmail(normalized) }); + return; + } + throw error; + } const user = rows[0]; if (user) { const token = await createVerificationToken(client, user.id); @@ -239,14 +248,27 @@ export async function verifyEmailToken(token: string): Promise { const { rows } = await client.query<{ id: string; user_id: string; token_hash: string }>( `SELECT id, user_id, token_hash FROM email_verification_tokens - WHERE used_at IS NULL AND expires_at > NOW()`, + WHERE token_hash = $1 AND used_at IS NULL AND expires_at > NOW() + FOR UPDATE`, + [tokenHash], ); const row = rows.find((candidate) => timingSafeEqualHex(candidate.token_hash, tokenHash)); if (!row) { await client.query("ROLLBACK"); return false; } - await client.query("UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL", [row.user_id]); + const consumed = await client.query( + "UPDATE email_verification_tokens SET used_at = NOW() WHERE id = $1 AND used_at IS NULL RETURNING id", + [row.id], + ); + if (consumed.rowCount === 0) { + await client.query("ROLLBACK"); + return false; + } + await client.query( + "UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id = $1 AND id <> $2 AND used_at IS NULL", + [row.user_id, row.id], + ); await client.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()), updated_at = NOW() WHERE id = $1", [row.user_id]); await client.query("SELECT pg_advisory_xact_lock(706365)"); const claimed = await client.query("SELECT value FROM app_settings WHERE key = 'orphan_races_claimed_by_user_id' FOR UPDATE"); @@ -300,14 +322,27 @@ export async function resetPassword(token: string, password: string): Promise( `SELECT id, user_id, token_hash FROM password_reset_tokens - WHERE used_at IS NULL AND expires_at > NOW()`, + WHERE token_hash = $1 AND used_at IS NULL AND expires_at > NOW() + FOR UPDATE`, + [tokenHash], ); const row = rows.find((candidate) => timingSafeEqualHex(candidate.token_hash, tokenHash)); if (!row) { await client.query("ROLLBACK"); return false; } - await client.query("UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL", [row.user_id]); + const consumed = await client.query( + "UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1 AND used_at IS NULL RETURNING id", + [row.id], + ); + if (consumed.rowCount === 0) { + await client.query("ROLLBACK"); + return false; + } + await client.query( + "UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id = $1 AND id <> $2 AND used_at IS NULL", + [row.user_id, row.id], + ); await client.query("UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1", [row.user_id, passwordHash]); await client.query("UPDATE sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL", [row.user_id]); await client.query("COMMIT"); @@ -346,3 +381,16 @@ export async function cleanupExpiredAuthRows(): Promise { await pool.query("DELETE FROM email_verification_tokens WHERE expires_at <= NOW() OR used_at IS NOT NULL"); await pool.query("DELETE FROM password_reset_tokens WHERE expires_at <= NOW() OR used_at IS NOT NULL"); } + +export function startAuthCleanupSchedule(intervalHours = config.authCleanupIntervalHours): NodeJS.Timeout { + const runCleanup = () => { + void cleanupExpiredAuthRows().catch((error) => { + console.error("[auth-cleanup] Failed:", error); + }); + }; + runCleanup(); + const safeHours = Number.isFinite(intervalHours) && intervalHours > 0 ? intervalHours : 24; + const timer = setInterval(runCleanup, safeHours * 60 * 60 * 1000); + timer.unref?.(); + return timer; +} diff --git a/backend/src/config.ts b/backend/src/config.ts index 61bbffe..690572b 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -34,6 +34,23 @@ const useMockDb = process.env.CALENDAR_RUN_MOCK_DB === "1" || process.env.CALENDAR_RUN_MOCK_DB?.toLowerCase() === "true"; +const securityProfile = process.env.SECURITY_PROFILE?.trim() || process.env.NODE_ENV || "development"; + +export function resolveTurnstileBypassToken(params: { + rawBypassToken?: string; + securityProfile: string; + useMockDb: boolean; +}): string { + const raw = params.rawBypassToken?.trim() ?? ""; + if (raw && params.securityProfile === "production" && !params.useMockDb) { + throw new Error("TURNSTILE_BYPASS_TOKEN is not allowed in production"); + } + if (raw) { + return raw; + } + return params.useMockDb ? "mock-turnstile-token" : ""; +} + export const config = { useMockDb, db: useMockDb @@ -73,9 +90,14 @@ export const config = { }, turnstile: { secretKey: process.env.TURNSTILE_SECRET_KEY?.trim() || (useMockDb ? "mock-turnstile-secret" : ""), - bypassToken: process.env.TURNSTILE_BYPASS_TOKEN?.trim() || (useMockDb ? "mock-turnstile-token" : ""), + bypassToken: resolveTurnstileBypassToken({ + rawBypassToken: process.env.TURNSTILE_BYPASS_TOKEN, + securityProfile, + useMockDb, + }), }, - securityProfile: process.env.SECURITY_PROFILE?.trim() || process.env.NODE_ENV || "development", + authCleanupIntervalHours: parseInt(process.env.AUTH_CLEANUP_INTERVAL_HOURS || "24", 10), + securityProfile, }; function parseCorsOrigins(): string | string[] { diff --git a/backend/src/db.ts b/backend/src/db.ts index 59e909d..3e934e0 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -101,6 +101,24 @@ function createMockPool(): Pool { return result([{ count: String(users.size) } as unknown as T]); } + if (sql.includes("SELECT COUNT(*)::text AS count FROM sessions")) { + const tokenHash = p[0] != null ? String(p[0]) : null; + const count = Array.from(sessions.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length; + return result([{ count: String(count) } as unknown as T]); + } + + if (sql.includes("SELECT COUNT(*)::text AS count FROM email_verification_tokens")) { + const tokenHash = p[0] != null ? String(p[0]) : null; + const count = Array.from(verificationTokens.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length; + return result([{ count: String(count) } as unknown as T]); + } + + if (sql.includes("SELECT COUNT(*)::text AS count FROM password_reset_tokens")) { + const tokenHash = p[0] != null ? String(p[0]) : null; + const count = Array.from(resetTokens.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length; + return result([{ count: String(count) } as unknown as T]); + } + if (sql.includes("SELECT id FROM users WHERE LOWER(BTRIM(email))")) { const email = String(p[0] ?? ""); const user = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email); @@ -114,6 +132,13 @@ function createMockPool(): Pool { } if (sql.includes("INSERT INTO users")) { + const email = String(p[0] ?? "").trim().toLowerCase(); + const existing = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email); + if (existing) { + const err = new Error("duplicate key") as Error & { code?: string }; + err.code = "23505"; + throw err; + } const id = crypto.randomUUID(); const row = { id, @@ -141,16 +166,37 @@ function createMockPool(): Pool { return result([row as unknown as T], "INSERT"); } + if (sql.includes("UPDATE email_verification_tokens SET used_at = NOW() WHERE id")) { + const id = String(p[0] ?? ""); + const row = verificationTokens.get(id); + if (!row || row.used_at != null) { + return result([], "UPDATE"); + } + row.used_at = new Date(); + return result([{ id: row.id } as unknown as T], "UPDATE"); + } + if (sql.includes("UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id")) { const userId = String(p[0] ?? ""); + const exceptId = p[1] != null ? String(p[1]) : null; for (const row of verificationTokens.values()) { - if (row.user_id === userId && row.used_at == null) { + if (row.user_id === userId && row.id !== exceptId && row.used_at == null) { row.used_at = new Date(); } } return result([], "UPDATE"); } + if (sql.includes("FROM email_verification_tokens") && sql.includes("token_hash =")) { + const tokenHash = String(p[0] ?? ""); + const now = Date.now(); + return result( + Array.from(verificationTokens.values()).filter( + (row) => row.token_hash === tokenHash && !row.used_at && new Date(row.expires_at).getTime() > now, + ) as T[], + ); + } + if (sql.includes("FROM email_verification_tokens") && sql.includes("WHERE used_at IS NULL")) { const now = Date.now(); return result( @@ -172,16 +218,37 @@ function createMockPool(): Pool { return result([row as unknown as T], "INSERT"); } + if (sql.includes("UPDATE password_reset_tokens SET used_at = NOW() WHERE id")) { + const id = String(p[0] ?? ""); + const row = resetTokens.get(id); + if (!row || row.used_at != null) { + return result([], "UPDATE"); + } + row.used_at = new Date(); + return result([{ id: row.id } as unknown as T], "UPDATE"); + } + if (sql.includes("UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id")) { const userId = String(p[0] ?? ""); + const exceptId = p[1] != null ? String(p[1]) : null; for (const row of resetTokens.values()) { - if (row.user_id === userId && row.used_at == null) { + if (row.user_id === userId && row.id !== exceptId && row.used_at == null) { row.used_at = new Date(); } } return result([], "UPDATE"); } + if (sql.includes("FROM password_reset_tokens") && sql.includes("token_hash =")) { + const tokenHash = String(p[0] ?? ""); + const now = Date.now(); + return result( + Array.from(resetTokens.values()).filter( + (row) => row.token_hash === tokenHash && !row.used_at && new Date(row.expires_at).getTime() > now, + ) as T[], + ); + } + if (sql.includes("FROM password_reset_tokens") && sql.includes("WHERE used_at IS NULL")) { const now = Date.now(); return result( @@ -273,6 +340,44 @@ function createMockPool(): Pool { return result([], "UPDATE"); } + if (sql.includes("DELETE FROM sessions WHERE expires_at <= NOW()")) { + const now = Date.now(); + let deleted = 0; + for (const [id, row] of sessions.entries()) { + const revokedAt = row.revoked_at ? new Date(row.revoked_at).getTime() : null; + const staleRevoked = revokedAt != null && revokedAt < now - 30 * 24 * 60 * 60 * 1000; + if (new Date(row.expires_at).getTime() <= now || staleRevoked) { + sessions.delete(id); + deleted += 1; + } + } + return result(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE"); + } + + if (sql.includes("DELETE FROM email_verification_tokens WHERE expires_at <= NOW()")) { + const now = Date.now(); + let deleted = 0; + for (const [id, row] of verificationTokens.entries()) { + if (new Date(row.expires_at).getTime() <= now || row.used_at != null) { + verificationTokens.delete(id); + deleted += 1; + } + } + return result(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE"); + } + + if (sql.includes("DELETE FROM password_reset_tokens WHERE expires_at <= NOW()")) { + const now = Date.now(); + let deleted = 0; + for (const [id, row] of resetTokens.entries()) { + if (new Date(row.expires_at).getTime() <= now || row.used_at != null) { + resetTokens.delete(id); + deleted += 1; + } + } + return result(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE"); + } + if (sql.includes("SELECT value FROM app_settings")) { const value = appSettings.get("orphan_races_claimed_by_user_id"); return value ? result([{ value } as unknown as T]) : emptyResult(); diff --git a/backend/src/index.ts b/backend/src/index.ts index bdf7066..bfd12bd 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,7 +1,9 @@ import { config } from "./config"; import { createApp } from "./app"; +import { startAuthCleanupSchedule } from "./authService"; const app = createApp(); +startAuthCleanupSchedule(); app.listen(config.apiPort, () => { console.log(`[api] Listening on http://localhost:${config.apiPort}`); diff --git a/backend/test/app.test.ts b/backend/test/app.test.ts index 241e07a..5075992 100644 --- a/backend/test/app.test.ts +++ b/backend/test/app.test.ts @@ -1,8 +1,15 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import request from "supertest"; -import { createApp } from "../src/app"; -import { createResetToken, resetPassword } from "../src/authService"; +import { buildHelmetOptions, createApp } from "../src/app"; +import { + cleanupExpiredAuthRows, + createResetToken, + createVerificationToken, + resetPassword, + verifyEmailToken, +} from "../src/authService"; +import { resolveTurnstileBypassToken } from "../src/config"; import { pool } from "../src/db"; import { extractRaceCoverImageFromHtml } from "../src/raceCoverImage"; import { hashPassword, normalizeEmail } from "../src/security"; @@ -30,6 +37,14 @@ async function authAgent() { } async function createVerifiedUser(email: string, password: string) { + return createUser(email, password, true); +} + +async function createUnverifiedUser(email: string, password: string) { + return createUser(email, password, false); +} + +async function createUser(email: string, password: string, verified: boolean) { const passwordHash = await hashPassword(password); const inserted = await pool.query<{ id: string }>( `INSERT INTO users (email, password_hash) @@ -37,12 +52,22 @@ async function createVerifiedUser(email: string, password: string) { RETURNING id`, [normalizeEmail(email), passwordHash], ); - await pool.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()) WHERE id = $1", [ - inserted.rows[0].id, - ]); + if (verified) { + await pool.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()) WHERE id = $1", [ + inserted.rows[0].id, + ]); + } return inserted.rows[0].id; } +async function countByTokenHash(table: "sessions" | "email_verification_tokens" | "password_reset_tokens", tokenHash: string) { + const { rows } = await pool.query<{ count: string }>( + `SELECT COUNT(*)::text AS count FROM ${table} WHERE token_hash = $1`, + [tokenHash], + ); + return Number(rows[0]?.count ?? "0"); +} + test("GET /api/health returns ok", async () => { const res = await request(app).get("/api/health").expect(200); assert.equal(res.body.status, "ok"); @@ -62,6 +87,61 @@ test("GET /api/ready succeeds with mock database", async () => { assert.equal(res.body.db, "connected"); }); +test("production config rejects Turnstile bypass token", () => { + assert.throws( + () => + resolveTurnstileBypassToken({ + rawBypassToken: "unsafe-bypass", + securityProfile: "production", + useMockDb: false, + }), + /TURNSTILE_BYPASS_TOKEN/, + ); + assert.equal( + resolveTurnstileBypassToken({ + rawBypassToken: "dev-bypass", + securityProfile: "development", + useMockDb: false, + }), + "dev-bypass", + ); + assert.equal( + resolveTurnstileBypassToken({ + rawBypassToken: "mock-bypass", + securityProfile: "production", + useMockDb: true, + }), + "mock-bypass", + ); +}); + +test("production CSP allows Turnstile without unsafe script directives", () => { + const options = buildHelmetOptions("production") as any; + const directives = options.contentSecurityPolicy.directives; + + assert.ok(directives.scriptSrc.includes("https://challenges.cloudflare.com")); + assert.ok(directives.frameSrc.includes("https://challenges.cloudflare.com")); + assert.ok(directives.connectSrc.includes("https://challenges.cloudflare.com")); + assert.ok(!directives.scriptSrc.includes("'unsafe-inline'")); + assert.ok(!directives.scriptSrc.includes("'unsafe-eval'")); + assert.deepEqual(directives.frameAncestors, ["'none'"]); + assert.deepEqual(directives.objectSrc, ["'none'"]); +}); + +test("duplicate registration keeps generic accepted response", async () => { + const payload = { + email: "duplicate-register@example.com", + password: "correct horse battery staple", + turnstileToken: "mock-turnstile-token", + }; + + const first = await request(app).post("/api/auth/register").send(payload).expect(202); + const second = await request(app).post("/api/auth/register").send(payload).expect(202); + + assert.deepEqual(first.body, { ok: true }); + assert.deepEqual(second.body, { ok: true }); +}); + test("GET /api/races rejects invalid year", async () => { const { agent } = await authAgent(); const res = await agent.get("/api/races?year=bad").expect(400); @@ -136,6 +216,63 @@ test("new password reset token invalidates previous token", async () => { assert.equal(await resetPassword(first, "new correct horse battery staple"), false); assert.equal(await resetPassword(second, "new correct horse battery staple"), true); + assert.equal(await resetPassword(second, "new correct horse battery staple"), false); +}); + +test("email verification token is single use", async () => { + const userId = await createUnverifiedUser("verify-once@example.com", "correct horse battery staple"); + const client = await pool.connect(); + const token = await createVerificationToken(client, userId); + client.release(); + + assert.equal(await verifyEmailToken(token), true); + assert.equal(await verifyEmailToken(token), false); +}); + +test("auth cleanup removes expired rows and keeps active rows", async () => { + const userId = await createVerifiedUser("cleanup@example.com", "correct horse battery staple"); + const past = new Date(Date.now() - 60_000); + const future = new Date(Date.now() + 60_000); + const expiredSession = "expired-session-token-hash"; + const activeSession = "active-session-token-hash"; + const expiredVerify = "expired-verify-token-hash"; + const activeVerify = "active-verify-token-hash"; + const expiredReset = "expired-reset-token-hash"; + const activeReset = "active-reset-token-hash"; + + await pool.query( + "INSERT INTO sessions (user_id, token_hash, csrf_token_hash, expires_at) VALUES ($1, $2, $3, $4)", + [userId, expiredSession, "expired-csrf", past], + ); + await pool.query( + "INSERT INTO sessions (user_id, token_hash, csrf_token_hash, expires_at) VALUES ($1, $2, $3, $4)", + [userId, activeSession, "active-csrf", future], + ); + await pool.query( + "INSERT INTO email_verification_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)", + [userId, expiredVerify, past], + ); + await pool.query( + "INSERT INTO email_verification_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)", + [userId, activeVerify, future], + ); + await pool.query( + "INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)", + [userId, expiredReset, past], + ); + await pool.query( + "INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)", + [userId, activeReset, future], + ); + + await cleanupExpiredAuthRows(); + + assert.equal(await countByTokenHash("sessions", expiredSession), 0); + assert.equal(await countByTokenHash("sessions", activeSession), 1); + assert.equal(await countByTokenHash("email_verification_tokens", expiredVerify), 0); + assert.equal(await countByTokenHash("email_verification_tokens", activeVerify), 1); + assert.equal(await countByTokenHash("password_reset_tokens", expiredReset), 0); + assert.equal(await countByTokenHash("password_reset_tokens", activeReset), 1); }); test("extractRaceCoverImageFromHtml prefers runc.run intro image", () => {