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[]; transaction = Record>( name: string, options?: CookieOptions, ): TransactionCookie; } export interface TransactionCookie> { set(value: T): void; consume(): T | undefined; clear(): void; } 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"; const SESSION_TTL_MS = 1000 * 60 * 60 * 24; const SESSION_ABSOLUTE_TTL_MS = 1000 * 60 * 60 * 24 * 7; const SESSION_GC_EVERY = 500; export interface SessionPolicy { cookieName?: string; idleTimeoutMs?: number; absoluteTimeoutMs?: number; sameSite?: NonNullable; secure?: boolean; } let sessionPolicy: Required< Pick > & Pick = { cookieName: SESSION_COOKIE, idleTimeoutMs: SESSION_TTL_MS, absoluteTimeoutMs: SESSION_ABSOLUTE_TTL_MS, sameSite: "Lax", }; export function setSessionPolicy(policy: SessionPolicy): void { sessionPolicy = { ...sessionPolicy, ...policy, idleTimeoutMs: Math.max(60_000, policy.idleTimeoutMs ?? sessionPolicy.idleTimeoutMs), absoluteTimeoutMs: Math.max( policy.idleTimeoutMs ?? sessionPolicy.idleTimeoutMs, policy.absoluteTimeoutMs ?? sessionPolicy.absoluteTimeoutMs, ), }; } /** A stored session: its data plus an absolute expiry timestamp (ms). */ export interface SessionEntry { data: Record; expiresAt: number; /** Creation time used for the absolute session lifetime. Optional for old backends. */ createdAt?: number; lastAccessAt?: 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 || (entry.createdAt ?? now) + sessionPolicy.absoluteTimeoutMs <= 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; absoluteTtlMs?: number; cookieName?: string; sameSite?: NonNullable; secure?: boolean; } = {}, ): Middleware { const ttlMs = options.ttlMs ?? sessionPolicy.idleTimeoutMs; const absoluteTtlMs = options.absoluteTtlMs ?? sessionPolicy.absoluteTimeoutMs; const cookieName = options.cookieName ?? sessionPolicy.cookieName; return async (ctx: Context, next) => { let id = ctx.cookies.get(cookieName); let entry = id ? await backend.load(id) : undefined; if ( id && entry && (entry.expiresAt <= Date.now() || (entry.createdAt ?? Date.now()) + absoluteTtlMs <= 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( cookieName, id, sessionCookieOptions(options.secure ?? ctx.url.protocol === "https:", options.sameSite), ); } if (!entry) { const now = Date.now(); entry = { data: {}, expiresAt: now + ttlMs, createdAt: now, lastAccessAt: now }; } 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(); const now = Date.now(); entry = { data, expiresAt: now + ttlMs, createdAt: now, lastAccessAt: now }; ctx.cookies.set( cookieName, id, sessionCookieOptions(options.secure ?? ctx.url.protocol === "https:", options.sameSite), ); }, clear() { if (id) destroys.add(id); entry = undefined; id = undefined; ctx.cookies.delete( cookieName, sessionCookieOptions(options.secure ?? ctx.url.protocol === "https:", options.sameSite), ); }, }; 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; entry.lastAccessAt = Date.now(); 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; const now = Date.now(); if (entry.expiresAt <= now || (entry.createdAt ?? now) + sessionPolicy.absoluteTimeoutMs <= now) { sessionBackend.delete(id); return undefined; } entry.lastAccessAt = now; entry.createdAt ??= now; entry.expiresAt = now + sessionPolicy.idleTimeoutMs; // 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[] = []; const store: CookieStore = { 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]; }, transaction>(name: string, options: CookieOptions = {}) { const policy: CookieOptions = { path: "/", httpOnly: true, sameSite: "Lax", maxAge: 600, secure: new URL(req.url).protocol === "https:", ...options, }; return { set(value: T) { const json = JSON.stringify(value); const encoded = btoa(unescape(encodeURIComponent(json))) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); store.set(name, encoded, policy); }, consume(): T | undefined { const encoded = store.get(name); store.delete(name, policy); if (!encoded) return undefined; try { const padded = encoded .replace(/-/g, "+") .replace(/_/g, "/") .padEnd(Math.ceil(encoded.length / 4) * 4, "="); return JSON.parse(decodeURIComponent(escape(atob(padded)))) as T; } catch { return undefined; } }, clear() { store.delete(name, policy); }, }; }, }; return store; } export function createSessionStore( cookies: CookieStore, req: Request, cookieName = sessionPolicy.cookieName, secure = sessionPolicy.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()); } const now = Date.now(); entry = { data: {}, expiresAt: now + sessionPolicy.idleTimeoutMs, createdAt: now, lastAccessAt: now, }; 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(); const now = Date.now(); entry = { data, expiresAt: now + sessionPolicy.idleTimeoutMs, createdAt: now, lastAccessAt: now, }; 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, sameSite: NonNullable = sessionPolicy.sameSite, ): CookieOptions { return { httpOnly: true, path: "/", sameSite, 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 (key === "__proto__" || key === "prototype" || key === "constructor") continue; 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; }