session-tokens.ts
126 lines · ios-qa/daemon/src/session-tokens.ts at 3f79d81
1// Short-lived session token store. In-memory only (never disk). Refreshable
2// via /session/heartbeat. Listable and revokable from loopback listener.
4import { randomBytes } from 'crypto';
5import type { Capability, SessionToken } from './types';
6import { capabilityCovers } from './types';
8const TOKEN_BYTES = 32; // 256-bit
9const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1h per D9
10const MAX_TTL_MS = 24 * 60 * 60 * 1000; // 24h hard cap
12export class SessionTokenStore {
13 private tokens = new Map<string, SessionToken>();
14 private mintsPerIdentity = new Map<string, number[]>(); // ts (ms) for rate limiting
16 constructor(
17 private now: () => number = () => Date.now(),
18 ) {}
20 /**
21 * Mint a session token. Returns null on rate limit.
22 */
23 mint(opts: {
24 identity: string;
25 capability: Capability;
26 ttlMs?: number;
27 deviceUdid?: string | null;
28 origin: SessionToken['origin'];
29 }): SessionToken | { error: 'rate_limited' } {
30 if (!this.checkRateLimit(opts.identity)) {
31 return { error: 'rate_limited' };
32 }
33 const ttl = Math.min(opts.ttlMs ?? DEFAULT_TTL_MS, MAX_TTL_MS);
34 const token = randomBytes(TOKEN_BYTES).toString('base64url');
35 const expires_at = this.now() + ttl;
36 const session: SessionToken = {
37 token,
38 identity: opts.identity,
39 capability: opts.capability,
40 expires_at,
41 device_udid: opts.deviceUdid ?? null,
42 origin: opts.origin,
43 };
44 this.tokens.set(token, session);
45 return session;
46 }
48 /**
49 * Validate a token. Returns the session if valid (token exists, not
50 * expired). Otherwise returns null with a reason for the audit log.
51 */
52 validate(token: string | null | undefined, need: Capability):
53 | { ok: true; session: SessionToken }
54 | { ok: false; reason: 'no_token' | 'invalid_token' | 'expired_token' | 'capability_insufficient' } {
55 if (!token) return { ok: false, reason: 'no_token' };
56 const s = this.tokens.get(token);
57 if (!s) return { ok: false, reason: 'invalid_token' };
58 if (s.expires_at < this.now()) {
59 this.tokens.delete(token);
60 return { ok: false, reason: 'expired_token' };
61 }
62 if (!capabilityCovers(s.capability, need)) {
63 return { ok: false, reason: 'capability_insufficient' };
64 }
65 return { ok: true, session: s };
66 }
68 /**
69 * Slide token expiry forward by ttlMs. Caps at the token's original max
70 * (which itself is bounded by MAX_TTL_MS). Returns the new expiry.
71 */
72 heartbeat(token: string, ttlMs?: number): number | null {
73 const s = this.tokens.get(token);
74 if (!s) return null;
75 if (s.expires_at < this.now()) {
76 this.tokens.delete(token);
77 return null;
78 }
79 const newExpiry = this.now() + Math.min(ttlMs ?? DEFAULT_TTL_MS, MAX_TTL_MS);
80 s.expires_at = newExpiry;
81 return newExpiry;
82 }
84 revoke(token: string): boolean {
85 return this.tokens.delete(token);
86 }
88 revokeByIdentity(identity: string): number {
89 let count = 0;
90 for (const [token, s] of this.tokens) {
91 if (s.identity === identity) {
92 this.tokens.delete(token);
93 count++;
94 }
95 }
96 return count;
97 }
99 list(): SessionToken[] {
100 return [...this.tokens.values()];
101 }
103 // For tests: clear all state.
104 reset() {
105 this.tokens.clear();
106 this.mintsPerIdentity.clear();
107 }
109 /**
110 * Rate limit: 10 mints / 60s per identity. Sliding window.
111 */
112 private checkRateLimit(identity: string): boolean {
113 const now = this.now();
114 const window = 60_000;
115 const limit = 10;
116 const hits = this.mintsPerIdentity.get(identity) ?? [];
117 const recent = hits.filter(t => now - t < window);
118 if (recent.length >= limit) {
119 this.mintsPerIdentity.set(identity, recent);
120 return false;
121 }
122 recent.push(now);
123 this.mintsPerIdentity.set(identity, recent);
124 return true;
125 }
126}