524 lines
15 KiB
TypeScript
524 lines
15 KiB
TypeScript
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<string, string>;
|
|
has(name: string): boolean;
|
|
set(name: string, value: string, options?: CookieOptions): void;
|
|
delete(name: string, options?: CookieOptions): void;
|
|
headers(): string[];
|
|
transaction<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
name: string,
|
|
options?: CookieOptions,
|
|
): TransactionCookie<T>;
|
|
}
|
|
|
|
export interface TransactionCookie<T extends Record<string, unknown>> {
|
|
set(value: T): void;
|
|
consume(): T | undefined;
|
|
clear(): void;
|
|
}
|
|
|
|
export interface SessionStore {
|
|
id(): string;
|
|
get<T = unknown>(key: string): T | undefined;
|
|
getAll(): Record<string, unknown>;
|
|
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<string, string>;
|
|
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<CookieOptions["sameSite"]>;
|
|
secure?: boolean;
|
|
}
|
|
|
|
let sessionPolicy: Required<
|
|
Pick<SessionPolicy, "cookieName" | "idleTimeoutMs" | "absoluteTimeoutMs" | "sameSite">
|
|
> &
|
|
Pick<SessionPolicy, "secure"> = {
|
|
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<string, unknown>;
|
|
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<string, SessionEntry>();
|
|
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<SessionEntry | undefined>;
|
|
save(id: string, entry: SessionEntry): Promise<void>;
|
|
destroy(id: string): Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* 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<CookieOptions["sameSite"]>;
|
|
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<string>();
|
|
|
|
const ensure = (): Record<string, unknown> => {
|
|
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<T = unknown>(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<T extends Record<string, unknown>>(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<string, unknown> => {
|
|
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<T = unknown>(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<string, string> {
|
|
const out: Record<string, string> = {};
|
|
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<CookieOptions["sameSite"]> = sessionPolicy.sameSite,
|
|
): CookieOptions {
|
|
return {
|
|
httpOnly: true,
|
|
path: "/",
|
|
sameSite,
|
|
secure,
|
|
};
|
|
}
|
|
|
|
function parseLocalStorageHeader(header: string | null): Record<string, string> {
|
|
if (!header) return {};
|
|
try {
|
|
const parsed = JSON.parse(decodeURIComponent(header)) as unknown;
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
|
|
const out: Record<string, string> = {};
|
|
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<CookieOptions["sameSite"]>): 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;
|
|
}
|