Skill Tracer
Skills › gstack › browse › session-cookie-store.ts

session-cookie-store.ts

124 lines · browse/src/session-cookie-store.ts at 3f79d81

1/**
2 * Factory for expiring session-cookie registries.
3 *
4 * pty-session-cookie.ts and sse-session-cookie.ts were byte-identical modulo
5 * the cookie name — a security-critical parser/TTL/prune implementation that
6 * had to be fixed in two places (and a third hand-rolled copy of the cookie
7 * parse had already diverged in terminal-agent.ts). One implementation now;
8 * the two modules are thin instantiations that keep their names and their
9 * distinct threat-model docstrings.
10 *
11 * Deliberately NOT unified here: pty-session-lease.ts — that's a different
12 * contract (sessionId/secret separation, refresh, env-overridable TTL).
13 *
14 * SECURITY INVARIANT: this module must never import token-registry — cookie
15 * session tokens must not be valid as scoped tokens (the
16 * cookie-picker-auth-isolation pattern). Pinned by sse-session-cookie.test.ts.
17 */
18import * as crypto from 'crypto';
20interface Session {
21 createdAt: number;
22 expiresAt: number;
25export interface SessionCookieStore {
26 mint(): { token: string; expiresAt: number };
27 validate(token: string | null | undefined): boolean;
28 revoke(token: string | null | undefined): void;
29 extract(req: Request): string | null;
30 buildSetCookie(token: string): string;
31 /** Test-only reset. */
32 __reset(): void;
35export function createSessionCookieStore(opts: {
36 cookieName: string;
37 ttlMs: number;
38 maxSessions?: number;
39}): SessionCookieStore {
40 const { cookieName, ttlMs } = opts;
41 const maxSessions = opts.maxSessions ?? 10_000;
42 const sessions = new Map<string, Session>();
44 function pruneExpired(now: number): void {
45 // Opportunistic cleanup: check up to 20 entries per call so we don't
46 // stall on a massive registry. O(1) amortized. Runs on every mint AND
47 // on every validate so a steady reconnect flow can't outpace it.
48 let checked = 0;
49 for (const [token, session] of sessions) {
50 if (checked++ >= 20) break;
51 if (session.expiresAt <= now) sessions.delete(token);
52 }
53 // Hard cap as a backstop — if something still gets past opportunistic
54 // cleanup (e.g., all unexpired but registry enormous), drop the oldest.
55 while (sessions.size > maxSessions) {
56 const first = sessions.keys().next().value;
57 if (!first) break;
58 sessions.delete(first);
59 }
60 }
62 return {
63 mint() {
64 // 32 random bytes → 43-char URL-safe base64 (no padding). 256 bits.
65 const token = crypto.randomBytes(32).toString('base64url');
66 const now = Date.now();
67 const expiresAt = now + ttlMs;
68 sessions.set(token, { createdAt: now, expiresAt });
69 pruneExpired(now);
70 return { token, expiresAt };
71 },
73 validate(token) {
74 if (!token) return false;
75 const s = sessions.get(token);
76 if (!s) {
77 pruneExpired(Date.now());
78 return false;
79 }
80 if (Date.now() > s.expiresAt) {
81 sessions.delete(token);
82 pruneExpired(Date.now());
83 return false;
84 }
85 return true;
86 },
88 revoke(token) {
89 if (!token) return;
90 sessions.delete(token);
91 },
93 extract(req) {
94 const cookieHeader = req.headers.get('cookie');
95 if (!cookieHeader) return null;
96 for (const part of cookieHeader.split(';')) {
97 const [name, ...valueParts] = part.trim().split('=');
98 if (name === cookieName) {
99 return valueParts.join('=') || null;
100 }
101 }
102 return null;
103 },
105 /**
106 * Set-Cookie value:
107 * - HttpOnly: not readable from JS (mitigates XSS exfiltration).
108 * - SameSite=Strict: not sent on cross-site requests (mitigates
109 * CSRF/CSWSH).
110 * - Path=/: scope to the whole origin.
111 * - Max-Age matches the TTL.
112 * Secure is intentionally omitted: the daemon binds 127.0.0.1 over plain
113 * HTTP; Secure would prevent the browser from ever sending it back.
114 */
115 buildSetCookie(token) {
116 const maxAge = Math.floor(ttlMs / 1000);
117 return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`;
118 },
120 __reset() {
121 sessions.clear();
122 },
123 };