// /var/www/rp/src/lib/server/auth/session.ts // Signed cookie sessions. SESSION_SECRET must be set in .env. // Session ID is a random 32-byte hex string. The session row in the DB // carries the user_id and expires_at. The cookie carries only the session ID. import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; import { db } from '../db'; const SESSION_TTL_DAYS = 30; const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000; const COOKIE_NAME = 'thw_play_session'; function getSessionSecret(): string { const s = process.env.SESSION_SECRET; if (!s || s.length < 32) { throw new Error('SESSION_SECRET must be set in .env (32+ chars)'); } return s; } function sign(value: string): string { return createHmac('sha256', getSessionSecret()).update(value).digest('hex'); } function makeCookieValue(sessionId: string): string { return `${sessionId}.${sign(sessionId)}`; } function verifyCookieValue(cookieValue: string): string | null { const dotIdx = cookieValue.lastIndexOf('.'); if (dotIdx < 1) return null; const sessionId = cookieValue.slice(0, dotIdx); const providedSig = cookieValue.slice(dotIdx + 1); const expectedSig = sign(sessionId); if (providedSig.length !== expectedSig.length) return null; try { if (!timingSafeEqual(Buffer.from(providedSig, 'hex'), Buffer.from(expectedSig, 'hex'))) { return null; } } catch { return null; } return sessionId; } export interface SessionUser { id: string; username: string; global_name: string | null; avatar: string | null; email: string | null; display_name: string | null; created_at?: string; last_login_at?: string; } export function createSession(userId: string): { id: string; cookie: string; expiresAt: Date } { const id = randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + SESSION_TTL_MS); db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)').run( id, userId, expiresAt.toISOString() ); return { id, cookie: makeCookieValue(id), expiresAt }; } export function destroySession(sessionId: string): void { db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId); } export function getSessionUser(cookieValue: string | undefined | null): SessionUser | null { if (!cookieValue) return null; const sessionId = verifyCookieValue(cookieValue); if (!sessionId) return null; const row = db .prepare( `SELECT u.id, u.username, u.global_name, u.avatar, u.email, u.display_name, u.created_at, u.last_login_at, s.expires_at FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = ? AND s.expires_at > datetime('now')` ) .get(sessionId) as | (SessionUser & { expires_at: string }) | undefined; if (!row) return null; return { id: row.id, username: row.username, global_name: row.global_name, avatar: row.avatar, email: row.email, display_name: row.display_name ?? null, created_at: row.created_at, last_login_at: row.last_login_at }; } export function upsertUser(user: SessionUser): void { db.prepare( `INSERT INTO users (id, username, global_name, avatar, email, display_name, last_login_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(id) DO UPDATE SET username = excluded.username, global_name = excluded.global_name, avatar = excluded.avatar, email = excluded.email, display_name = excluded.display_name, last_login_at = datetime('now')` ).run(user.id, user.username, user.global_name, user.avatar, user.email, user.display_name ?? null); } export const SESSION_COOKIE = COOKIE_NAME; export const SESSION_TTL_DURATION = SESSION_TTL_DAYS * 24 * 60 * 60;