import type { Context, Middleware } from "./context.ts"; export interface CookieOptions { path?: string; domain?: string; maxAge?: number; expires?: Date | string; httpOnly?: boolean; secure?: boolean; sameSite?: "Strict" | "Lax" | "None" | "strict" | "lax" | "none"; } export interface CookieStore { get(name: string): string | undefined; getAll(): Record; has(name: string): boolean; set(name: string, value: string, options?: CookieOptions): void; delete(name: string, options?: CookieOptions): void; headers(): string[]; } export interface SessionStore { id(): string; get(key: string): T | undefined; getAll(): Record; set(key: string, value: unknown): void; delete(key: string): void; /** Issue a fresh session id, keeping the data — defends against fixation. */ regenerate(): void; clear(): void; } export interface LocalStorageSnapshot { get(key: string): string | undefined; getAll(): Record; has(key: string): boolean; } const SESSION_COOKIE = "wrnexus.sid"; /** Idle timeout: a session expires this long after its last access. */ const SESSION_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours /** Run a background sweep after this many new sessions (bounds memory). */ const SESSION_GC_EVERY = 500; /** A stored session: its data plus an absolute expiry timestamp (ms). */ export interface SessionEntry { data: Record; expiresAt: number; } /** * Pluggable session persistence. The default is process-local memory; swap in a * shared backend (Redis, SQL, etc.) via `setSessionBackend` so sessions survive * restarts and work across multiple instances. Methods are synchronous, so a * backend must be sync (e.g. `bun:sqlite`); async stores need a load/save * wrapper around the request (future work). */ export interface SessionBackend { get(id: string): SessionEntry | undefined; set(id: string, entry: SessionEntry): void; delete(id: string): void; /** Optional: drop expired entries. Called periodically by the store. */ gc?(now: number): void; } function createMemorySessionBackend(): SessionBackend { const map = new Map(); return { get: (id) => map.get(id), set: (id, entry) => void map.set(id, entry), delete: (id) => void map.delete(id), gc: (now) => { for (const [key, entry] of map) if (entry.expiresAt <= now) map.delete(key); }, }; } let sessionBackend: SessionBackend = createMemorySessionBackend(); let sessionsSinceGc = 0; /** Replace the session persistence backend (call once at startup). */ export function setSessionBackend(backend: SessionBackend): void { sessionBackend = backend; } /** * An ASYNC session store (Redis, a remote DB). Use it via the `loadSession` * middleware, which loads the session before the request and saves it after — * keeping the `ctx.session` API synchronous while persistence is shared across * instances. */ export interface AsyncSessionBackend { load(id: string): Promise; save(id: string, entry: SessionEntry): Promise; destroy(id: string): Promise; } /** * Back `ctx.session` with an async store. Register early (before anything reads * `ctx.session`). Loads once at the start of the request and saves once at the * end; regenerate/clear destroy the old id. */ export function loadSession( backend: AsyncSessionBackend, options: { ttlMs?: number } = {}, ): Middleware { const ttlMs = options.ttlMs ?? SESSION_TTL_MS; return async (ctx: Context, next) => { let id = ctx.cookies.get(SESSION_COOKIE); let entry = id ? await backend.load(id) : undefined; if (id && entry && entry.expiresAt <= Date.now()) { await backend.destroy(id); entry = undefined; id = undefined; } else if (id && !entry) { id = undefined; // unknown/expired id → anonymous } const destroys = new Set(); const ensure = (): Record => { if (!id) { id = randomId(); ctx.cookies.set(SESSION_COOKIE, id, sessionCookieOptions(ctx.url.protocol === "https:")); } if (!entry) entry = { data: {}, expiresAt: Date.now() + ttlMs }; return entry.data; }; ctx.session = { id() { ensure(); return id!; }, get(key: string): T | undefined { return (entry?.data[key] as T | undefined) ?? undefined; }, getAll() { return entry ? { ...entry.data } : {}; }, set(key, value) { ensure()[key] = value; }, delete(key) { if (entry) delete entry.data[key]; }, regenerate() { const data = entry?.data ?? {}; if (id) destroys.add(id); id = randomId(); entry = { data, expiresAt: Date.now() + ttlMs }; ctx.cookies.set(SESSION_COOKIE, id, sessionCookieOptions(ctx.url.protocol === "https:")); }, clear() { if (id) destroys.add(id); entry = undefined; id = undefined; ctx.cookies.delete(SESSION_COOKIE, sessionCookieOptions(ctx.url.protocol === "https:")); }, }; try { return await next(); } finally { for (const gone of destroys) if (gone !== id) await backend.destroy(gone); if (id && entry) { entry.expiresAt = Date.now() + ttlMs; await backend.save(id, entry); } } }; } const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; /** Read a live (non-expired) session entry, sliding its expiry forward. */ function readSessionEntry(id: string): SessionEntry | undefined { const entry = sessionBackend.get(id); if (!entry) return undefined; if (entry.expiresAt <= Date.now()) { sessionBackend.delete(id); return undefined; } entry.expiresAt = Date.now() + SESSION_TTL_MS; // sliding idle expiry sessionBackend.set(id, entry); // persist the slide (matters for external backends) return entry; } export function createCookieStore(req: Request): CookieStore { const incoming = parseCookieHeader(req.headers.get("cookie") ?? ""); const outgoing: string[] = []; return { get(name) { return incoming[name]; }, getAll() { return { ...incoming }; }, has(name) { return Object.prototype.hasOwnProperty.call(incoming, name); }, set(name, value, options) { incoming[name] = value; outgoing.push(serializeCookie(name, value, { path: "/", ...options })); }, delete(name, options) { delete incoming[name]; outgoing.push( serializeCookie(name, "", { path: "/", ...options, expires: new Date(0), maxAge: 0, }), ); }, headers() { return [...outgoing]; }, }; } export function createSessionStore( cookies: CookieStore, req: Request, cookieName = SESSION_COOKIE, secure = new URL(req.url).protocol === "https:", ): SessionStore { let id = cookies.get(cookieName); let entry = id ? readSessionEntry(id) : undefined; if (id && !entry) id = undefined; // expired or unknown → treat as anonymous const persist = (): void => { if (id && entry) sessionBackend.set(id, entry); }; const ensure = (): Record => { if (!id) { id = randomId(); cookies.set(cookieName, id, sessionCookieOptions(secure)); } entry = readSessionEntry(id); if (!entry) { if (++sessionsSinceGc >= SESSION_GC_EVERY) { sessionsSinceGc = 0; sessionBackend.gc?.(Date.now()); } entry = { data: {}, expiresAt: Date.now() + SESSION_TTL_MS }; sessionBackend.set(id, entry); } return entry.data; }; return { id() { ensure(); return id!; }, get(key: string): T | undefined { return (entry?.data[key] as T | undefined) ?? undefined; }, getAll() { return entry ? { ...entry.data } : {}; }, set(key, value) { ensure()[key] = value; persist(); }, delete(key) { if (entry) { delete entry.data[key]; persist(); } }, regenerate() { // Session fixation defense: move existing data under a brand-new id and // reissue the cookie, so any pre-login id an attacker planted is void. const data = entry?.data ?? {}; if (id) sessionBackend.delete(id); id = randomId(); entry = { data, expiresAt: Date.now() + SESSION_TTL_MS }; sessionBackend.set(id, entry); cookies.set(cookieName, id, sessionCookieOptions(secure)); }, clear() { if (id) sessionBackend.delete(id); entry = undefined; id = undefined; cookies.delete(cookieName, sessionCookieOptions(secure)); }, }; } export function createLocalStorageSnapshot(req: Request): LocalStorageSnapshot { const values = parseLocalStorageHeader(req.headers.get("x-wrnexus-local-storage")); return { get(key) { return values[key]; }, getAll() { return { ...values }; }, has(key) { return Object.prototype.hasOwnProperty.call(values, key); }, }; } export function applyCookieHeaders(ctx: { cookies?: CookieStore }, headers: Headers): void { for (const value of ctx.cookies?.headers() ?? []) { headers.append("Set-Cookie", value); } } function parseCookieHeader(header: string): Record { const out: Record = {}; for (const part of header.split(";")) { const index = part.indexOf("="); if (index < 0) continue; const name = part.slice(0, index).trim(); if (!name) continue; out[name] = safeDecode(part.slice(index + 1).trim()); } return out; } function serializeCookie(name: string, value: string, options: CookieOptions): string { if (!COOKIE_NAME.test(name)) throw new Error(`Invalid cookie name: ${name}`); const parts = [`${name}=${encodeURIComponent(value)}`]; if (options.maxAge !== undefined) parts.push(`Max-Age=${Math.floor(options.maxAge)}`); if (options.domain) parts.push(`Domain=${options.domain}`); if (options.path) parts.push(`Path=${options.path}`); if (options.expires) { const expires = options.expires instanceof Date ? options.expires : new Date(options.expires); parts.push(`Expires=${expires.toUTCString()}`); } if (options.httpOnly) parts.push("HttpOnly"); if (options.secure) parts.push("Secure"); if (options.sameSite) parts.push(`SameSite=${normalizeSameSite(options.sameSite)}`); return parts.join("; "); } function sessionCookieOptions(secure: boolean): CookieOptions { return { httpOnly: true, path: "/", sameSite: "Lax", secure, }; } function parseLocalStorageHeader(header: string | null): Record { if (!header) return {}; try { const parsed = JSON.parse(decodeURIComponent(header)) as unknown; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; const out: Record = {}; for (const [key, value] of Object.entries(parsed)) { if (typeof value === "string") out[key] = value; } return out; } catch { return {}; } } function normalizeSameSite(value: NonNullable): string { const lower = value.toLowerCase(); return lower === "strict" ? "Strict" : lower === "none" ? "None" : "Lax"; } function safeDecode(value: string): string { try { return decodeURIComponent(value); } catch { return value; } } /** A 256-bit cryptographically-random session id (no weak fallback). */ function randomId(): string { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); let out = ""; for (const b of bytes) out += b.toString(16).padStart(2, "0"); return out; }