// ───────────────────────────────────────────────────────────── // src/sessions.js — one-time codes + session cookies. // // Codes: 6 digits, HMAC-SHA256 with a server-side pepper. // Sessions: opaque 256-bit random IDs stored in SQLite, not JWTs. // Revoking a session is a DELETE; no signing keys to rotate. // ───────────────────────────────────────────────────────────── import crypto from 'node:crypto'; import { q } from './db.js'; export const CODE_TTL_MS = 10 * 60 * 1000; // 10 minutes export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days export const COOKIE_NAME = 'fenja_session'; // ─── Crypto helpers ────────────────────────────────────────── export function randomCode() { // randomInt is cryptographically secure on Node 20+ return crypto.randomInt(0, 1_000_000).toString().padStart(6, '0'); } export function randomSessionId() { return crypto.randomBytes(32).toString('hex'); // 64 hex chars } export function hashCode(code) { const pepper = process.env.CODE_PEPPER; if (!pepper || pepper.length < 32) { throw new Error('CODE_PEPPER is missing or too short. Generate with: openssl rand -hex 32'); } return crypto.createHmac('sha256', pepper).update(code).digest('hex'); } export function constantTimeEqual(a, b) { const bufA = Buffer.from(a); const bufB = Buffer.from(b); if (bufA.length !== bufB.length) return false; return crypto.timingSafeEqual(bufA, bufB); } // ─── Session lifecycle ─────────────────────────────────────── export function issueSession(req, res, email) { const id = randomSessionId(); const now = Date.now(); q.createSession.run( id, email, now, now + SESSION_TTL_MS, req.ip || null, req.get('user-agent')?.slice(0, 500) || null ); res.cookie(COOKIE_NAME, id, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: SESSION_TTL_MS, }); } export function clearSession(req, res) { const id = req.cookies?.[COOKIE_NAME]; if (id) q.deleteSession.run(id); res.clearCookie(COOKIE_NAME, { path: '/' }); } export function currentSession(req) { const id = req.cookies?.[COOKIE_NAME]; if (!id) return null; return q.getSession.get(id, Date.now()) || null; }