From 5914f41ea198020ca650ecef1e602ae986df4547 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 27 May 2026 13:33:22 +0800 Subject: [PATCH] feat(auth): add SessionStore with rolling renewal and at-rest token hashing Co-Authored-By: Claude Sonnet 4.6 --- src/data/sessions.test.ts | 103 ++++++++++++++++++++++++++++++++++++++ src/data/sessions.ts | 87 ++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 src/data/sessions.test.ts create mode 100644 src/data/sessions.ts diff --git a/src/data/sessions.test.ts b/src/data/sessions.test.ts new file mode 100644 index 0000000..376690b --- /dev/null +++ b/src/data/sessions.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createHash } from "node:crypto"; +import { createDatabase, type BotDatabase } from "./database.js"; +import { createUserStore, type UserStore } from "./users.js"; +import { createSessionStore, type SessionStore, SESSION_TTL_MS, SESSION_TOUCH_INTERVAL_MS } from "./sessions.js"; + +function sha256(token: string) { + return createHash("sha256").update(token).digest("hex"); +} + +describe("SessionStore", () => { + let botDb: BotDatabase; + let users: UserStore; + let sessions: SessionStore; + let userId: string; + + beforeEach(async () => { + botDb = createDatabase(":memory:"); + users = createUserStore(botDb.db); + sessions = createSessionStore(botDb.db); + const u = await users.createUser("alice", "pw"); + userId = u.id; + }); + + afterEach(() => { + vi.useRealTimers(); + botDb.close(); + }); + + it("createSession returns a raw token whose sha256 matches the DB row id", () => { + const { token } = sessions.createSession(userId); + const row = botDb.db.prepare("SELECT id FROM sessions").get() as { id: string }; + expect(row.id).toBe(sha256(token)); + expect(row.id).not.toBe(token); + }); + + it("validateAndTouch returns the user for a fresh token", () => { + const { token } = sessions.createSession(userId); + const result = sessions.validateAndTouch(token); + expect(result).not.toBeNull(); + expect(result!.userId).toBe(userId); + expect(result!.username).toBe("alice"); + }); + + it("validateAndTouch returns null and deletes the row for an expired session", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + const { token } = sessions.createSession(userId); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + SESSION_TTL_MS + 1000); + expect(sessions.validateAndTouch(token)).toBeNull(); + const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n; + expect(remaining).toBe(0); + }); + + it("validateAndTouch does not write the DB if called again within the touch interval", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + const { token } = sessions.createSession(userId); + const before = botDb.db.prepare("SELECT lastSeenAt FROM sessions").get() as { lastSeenAt: number }; + vi.advanceTimersByTime(SESSION_TOUCH_INTERVAL_MS - 1000); + sessions.validateAndTouch(token); + const after = botDb.db.prepare("SELECT lastSeenAt FROM sessions").get() as { lastSeenAt: number }; + expect(after.lastSeenAt).toBe(before.lastSeenAt); + }); + + it("validateAndTouch writes lastSeenAt and extends expiresAt past the touch interval", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + const { token, expiresAt: initialExpiry } = sessions.createSession(userId); + vi.advanceTimersByTime(SESSION_TOUCH_INTERVAL_MS + 1000); + sessions.validateAndTouch(token); + const row = botDb.db.prepare("SELECT lastSeenAt, expiresAt FROM sessions").get() as { lastSeenAt: number; expiresAt: number }; + expect(row.lastSeenAt).toBe(Date.now()); + expect(row.expiresAt).toBeGreaterThan(initialExpiry); + }); + + it("deleteSession removes the row", () => { + const { token } = sessions.createSession(userId); + sessions.deleteSession(token); + const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n; + expect(remaining).toBe(0); + expect(sessions.validateAndTouch(token)).toBeNull(); + }); + + it("deleteAllForUser keeps the exceptToken session", () => { + const a = sessions.createSession(userId); + const b = sessions.createSession(userId); + sessions.deleteAllForUser(userId, a.token); + expect(sessions.validateAndTouch(a.token)).not.toBeNull(); + expect(sessions.validateAndTouch(b.token)).toBeNull(); + }); + + it("cleanupExpired removes only expired rows", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + sessions.createSession(userId); // expires later + vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + SESSION_TTL_MS + 1000); + sessions.createSession(userId); // fresh + sessions.cleanupExpired(); + const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n; + expect(remaining).toBe(1); + }); +}); diff --git a/src/data/sessions.ts b/src/data/sessions.ts new file mode 100644 index 0000000..ce29c4f --- /dev/null +++ b/src/data/sessions.ts @@ -0,0 +1,87 @@ +import { createHash, randomBytes } from "node:crypto"; +import type Database from "better-sqlite3"; + +export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +export const SESSION_TOUCH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour + +export interface SessionValidation { + userId: string; + username: string; +} + +export interface SessionStore { + createSession(userId: string): { token: string; expiresAt: number }; + validateAndTouch(rawToken: string): SessionValidation | null; + deleteSession(rawToken: string): void; + deleteAllForUser(userId: string, exceptToken?: string): void; + cleanupExpired(): void; +} + +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +export function createSessionStore(db: Database.Database): SessionStore { + const insertStmt = db.prepare( + "INSERT INTO sessions (id, userId, createdAt, expiresAt, lastSeenAt) VALUES (?, ?, ?, ?, ?)" + ); + const selectStmt = db.prepare(` + SELECT s.id, s.userId, s.expiresAt, s.lastSeenAt, u.username + FROM sessions s INNER JOIN users u ON u.id = s.userId + WHERE s.id = ? + `); + const deleteByIdStmt = db.prepare("DELETE FROM sessions WHERE id = ?"); + const touchStmt = db.prepare( + "UPDATE sessions SET lastSeenAt = ?, expiresAt = ? WHERE id = ?" + ); + const deleteAllForUserStmt = db.prepare("DELETE FROM sessions WHERE userId = ?"); + const deleteAllForUserExceptStmt = db.prepare( + "DELETE FROM sessions WHERE userId = ? AND id != ?" + ); + const cleanupStmt = db.prepare("DELETE FROM sessions WHERE expiresAt < ?"); + + return { + createSession(userId) { + const token = randomBytes(32).toString("base64url"); + const id = hashToken(token); + const now = Date.now(); + const expiresAt = now + SESSION_TTL_MS; + insertStmt.run(id, userId, now, expiresAt, now); + return { token, expiresAt }; + }, + + validateAndTouch(rawToken) { + if (!rawToken) return null; + const id = hashToken(rawToken); + const row = selectStmt.get(id) as + | { id: string; userId: string; expiresAt: number; lastSeenAt: number; username: string } + | undefined; + if (!row) return null; + const now = Date.now(); + if (row.expiresAt < now) { + deleteByIdStmt.run(id); + return null; + } + if (now - row.lastSeenAt > SESSION_TOUCH_INTERVAL_MS) { + touchStmt.run(now, now + SESSION_TTL_MS, id); + } + return { userId: row.userId, username: row.username }; + }, + + deleteSession(rawToken) { + deleteByIdStmt.run(hashToken(rawToken)); + }, + + deleteAllForUser(userId, exceptToken) { + if (exceptToken) { + deleteAllForUserExceptStmt.run(userId, hashToken(exceptToken)); + } else { + deleteAllForUserStmt.run(userId); + } + }, + + cleanupExpired() { + cleanupStmt.run(Date.now()); + }, + }; +}