refresh-token-core.test.mjs
388 lines 16.1 KB
Raw
sha256:baa800aedf841dbf32081aa7b2befa288ac33dfc7175ac55014c55c4d8c742f9 docs: move durable-auth freeze/evidence to local developmen… Human 11 days ago
1 /**
2 * Refresh-token core tests — Aaron's 7-tier standard.
3 *
4 * Tiers covered here (pure module; HTTP end-to-end lands with route wiring):
5 * 1. unit — generate/hash/parse + each operation in isolation
6 * 2. integration — multi-step login -> rotate -> rotate chains
7 * 3. lifecycle (e2e) — full session lifecycle within the module boundary
8 * 4. stress — large stores and long rotation chains
9 * 5. data-integrity — purity (no input mutation), persisted shape, no secret leakage
10 * 6. performance — rotation stays O(1)-ish under a large store
11 * 7. security — reuse detection, secret-at-rest, tampering, expiry, family cap
12 */
13
14 import { describe, it } from 'node:test';
15 import assert from 'node:assert/strict';
16 import {
17 DEFAULT_TOKEN_TTL_MS,
18 DEFAULT_FAMILY_TTL_MS,
19 REFRESH_FAILURE,
20 hashSecret,
21 generateRefreshToken,
22 parseToken,
23 issueToken,
24 rotateToken,
25 revokeToken,
26 revokeFamily,
27 revokeAllForSub,
28 pruneExpired,
29 } from '../hub/lib/refresh-token-core.mjs';
30
31 const SUB = 'google:123456';
32 const T0 = 1_000_000_000_000; // fixed base "now" for deterministic tests
33
34 /**
35 * Build a large store directly (O(n)) for performance/stress probes, instead of calling
36 * issueToken n times (which clones the growing store each call → O(n^2) and slows CI).
37 * @returns {{ records: Record<string, object>, firstToken: string }}
38 */
39 function buildLargeStore(n, now) {
40 const records = {};
41 let firstToken = null;
42 for (let i = 0; i < n; i++) {
43 const g = generateRefreshToken();
44 records[g.id] = {
45 sub: `google:${i}`,
46 family_id: `family-${i}`,
47 token_hash: g.tokenHash,
48 created_at: now,
49 expires_at: now + DEFAULT_TOKEN_TTL_MS,
50 family_expires_at: now + DEFAULT_FAMILY_TTL_MS,
51 rotated_to: null,
52 used_at: null,
53 revoked: false,
54 meta: {},
55 };
56 if (i === 0) firstToken = g.token;
57 }
58 return { records, firstToken };
59 }
60
61 // ---------------------------------------------------------------------------
62 // 1. unit
63 // ---------------------------------------------------------------------------
64 describe('1. unit — primitives', () => {
65 it('generateRefreshToken returns id.secret with a stored hash and no plaintext reuse', () => {
66 const t = generateRefreshToken();
67 assert.ok(t.id && t.secret && t.token && t.tokenHash);
68 assert.equal(t.token, `${t.id}.${t.secret}`);
69 assert.equal(t.tokenHash, hashSecret(t.secret));
70 assert.notEqual(t.tokenHash, t.secret, 'hash must differ from secret');
71 });
72
73 it('generateRefreshToken is unpredictable (no collisions across many draws)', () => {
74 const seen = new Set();
75 for (let i = 0; i < 5000; i++) {
76 const t = generateRefreshToken();
77 assert.ok(!seen.has(t.token), 'tokens must be unique');
78 seen.add(t.token);
79 }
80 });
81
82 it('parseToken splits valid tokens and rejects malformed input', () => {
83 assert.deepEqual(parseToken('abc.def'), { id: 'abc', secret: 'def' });
84 assert.equal(parseToken(''), null);
85 assert.equal(parseToken('noseparator'), null);
86 assert.equal(parseToken('.leadingdot'), null);
87 assert.equal(parseToken('trailingdot.'), null);
88 assert.equal(parseToken('a.b.c'), null, 'secret may not contain a dot');
89 assert.equal(parseToken(null), null);
90 assert.equal(parseToken(42), null);
91 });
92
93 it('hashSecret is deterministic and base64url', () => {
94 assert.equal(hashSecret('x'), hashSecret('x'));
95 assert.notEqual(hashSecret('x'), hashSecret('y'));
96 assert.match(hashSecret('x'), /^[A-Za-z0-9_-]+$/);
97 });
98
99 it('issueToken requires a sub', () => {
100 assert.throws(() => issueToken({}, { sub: '' }), /sub is required/);
101 assert.throws(() => issueToken({}, {}), /sub is required/);
102 });
103
104 it('issueToken creates exactly one record with the expected fields', () => {
105 const { records, token, id, familyId } = issueToken({}, { sub: SUB, now: T0 });
106 assert.equal(Object.keys(records).length, 1);
107 const rec = records[id];
108 assert.equal(rec.sub, SUB);
109 assert.equal(rec.family_id, familyId);
110 assert.equal(rec.expires_at, T0 + DEFAULT_TOKEN_TTL_MS);
111 assert.equal(rec.family_expires_at, T0 + DEFAULT_FAMILY_TTL_MS);
112 assert.equal(rec.rotated_to, null);
113 assert.equal(rec.revoked, false);
114 assert.equal(rec.token_hash, hashSecret(parseToken(token).secret));
115 });
116 });
117
118 // ---------------------------------------------------------------------------
119 // 2. integration
120 // ---------------------------------------------------------------------------
121 describe('2. integration — rotation chains', () => {
122 it('a valid token rotates to a new working token; the old one is consumed', () => {
123 const issued = issueToken({}, { sub: SUB, now: T0 });
124 const r1 = rotateToken(issued.records, issued.token, { now: T0 + 1000 });
125 assert.equal(r1.ok, true);
126 assert.equal(r1.sub, SUB);
127 assert.notEqual(r1.token, issued.token);
128
129 // New token works again
130 const r2 = rotateToken(r1.records, r1.token, { now: T0 + 2000 });
131 assert.equal(r2.ok, true);
132 assert.notEqual(r2.token, r1.token);
133 });
134
135 it('rotation preserves the family id across the chain', () => {
136 const issued = issueToken({}, { sub: SUB, now: T0 });
137 const r1 = rotateToken(issued.records, issued.token, { now: T0 + 1 });
138 const r2 = rotateToken(r1.records, r1.token, { now: T0 + 2 });
139 assert.equal(r1.familyId, issued.familyId);
140 assert.equal(r2.familyId, issued.familyId);
141 });
142
143 it('rotation carries the absolute family ceiling forward (rotation cannot extend it)', () => {
144 const issued = issueToken({}, { sub: SUB, now: T0 });
145 const familyCap = issued.records[issued.id].family_expires_at;
146 const r1 = rotateToken(issued.records, issued.token, { now: T0 + 5000 });
147 const newRec = Object.values(r1.records).find((rec) => rec.rotated_to === null && !rec.revoked);
148 assert.equal(newRec.family_expires_at, familyCap, 'family ceiling must not move on rotation');
149 });
150
151 it('two independent logins create independent families', () => {
152 const a = issueToken({}, { sub: SUB, now: T0 });
153 const b = issueToken(a.records, { sub: SUB, now: T0 });
154 assert.notEqual(a.familyId, b.familyId);
155 // Revoking family A leaves B usable.
156 const burned = revokeFamily(b.records, a.familyId, T0);
157 const rb = rotateToken(burned, b.token, { now: T0 + 1 });
158 assert.equal(rb.ok, true);
159 });
160 });
161
162 // ---------------------------------------------------------------------------
163 // 3. lifecycle (end-to-end within the module)
164 // ---------------------------------------------------------------------------
165 describe('3. lifecycle — login, refresh repeatedly, logout', () => {
166 it('models a real session: login then many refreshes then logout invalidates', () => {
167 let { records, token } = issueToken({}, { sub: SUB, now: T0 });
168 let now = T0;
169 for (let i = 0; i < 50; i++) {
170 now += 60_000; // refresh every minute
171 const r = rotateToken(records, token, { now });
172 assert.equal(r.ok, true, `refresh #${i} should succeed`);
173 records = r.records;
174 token = r.token;
175 }
176 // Logout the current token.
177 const out = revokeToken(records, token);
178 assert.equal(out.revoked, true);
179 // The token can no longer be used.
180 const after = rotateToken(out.records, token, { now: now + 1000 });
181 assert.equal(after.ok, false);
182 assert.equal(after.reason, REFRESH_FAILURE.INVALID);
183 });
184 });
185
186 // ---------------------------------------------------------------------------
187 // 4. stress
188 // ---------------------------------------------------------------------------
189 describe('4. stress — large stores and long chains', () => {
190 it('handles many concurrent families without cross-contamination', () => {
191 let records = {};
192 const tokens = [];
193 for (let i = 0; i < 1500; i++) {
194 const res = issueToken(records, { sub: `google:${i}`, now: T0 });
195 records = res.records;
196 tokens.push(res.token);
197 }
198 assert.equal(Object.keys(records).length, 1500);
199 // Rotate a sampling; each must succeed and keep the store consistent.
200 for (const idx of [0, 750, 1499]) {
201 const r = rotateToken(records, tokens[idx], { now: T0 + 1 });
202 assert.equal(r.ok, true);
203 records = r.records;
204 }
205 });
206
207 it('survives a 500-deep rotation chain', () => {
208 let { records, token } = issueToken({}, { sub: SUB, now: T0 });
209 for (let i = 0; i < 500; i++) {
210 const r = rotateToken(records, token, { now: T0 + i });
211 assert.equal(r.ok, true);
212 records = r.records;
213 token = r.token;
214 }
215 });
216 });
217
218 // ---------------------------------------------------------------------------
219 // 5. data-integrity
220 // ---------------------------------------------------------------------------
221 describe('5. data-integrity — purity and no secret leakage', () => {
222 it('issueToken does not mutate the input records object', () => {
223 const input = {};
224 const frozen = Object.freeze(input);
225 const res = issueToken(frozen, { sub: SUB, now: T0 });
226 assert.equal(Object.keys(input).length, 0, 'input must be untouched');
227 assert.equal(Object.keys(res.records).length, 1);
228 });
229
230 it('rotateToken does not mutate the input records object', () => {
231 const issued = issueToken({}, { sub: SUB, now: T0 });
232 const snapshot = JSON.stringify(issued.records);
233 rotateToken(issued.records, issued.token, { now: T0 + 1 });
234 assert.equal(JSON.stringify(issued.records), snapshot, 'rotate must not mutate caller records');
235 });
236
237 it('the raw secret is never present anywhere in the persisted records', () => {
238 const issued = issueToken({}, { sub: SUB, now: T0 });
239 const { secret } = parseToken(issued.token);
240 assert.ok(!JSON.stringify(issued.records).includes(secret), 'secret must not be stored');
241 });
242
243 it('persisted records are JSON-serializable and round-trip identically', () => {
244 const issued = issueToken({}, { sub: SUB, now: T0 });
245 const round = JSON.parse(JSON.stringify(issued.records));
246 const r = rotateToken(round, issued.token, { now: T0 + 1 });
247 assert.equal(r.ok, true, 'rotation must work on a JSON round-tripped store');
248 });
249
250 it('meta is sanitized to known short fields only', () => {
251 const issued = issueToken({}, {
252 sub: SUB,
253 now: T0,
254 meta: {
255 ua: 'x'.repeat(1000),
256 ip: 'y'.repeat(200),
257 agent: 'z'.repeat(200),
258 client_id: 'cid',
259 scopes: 'vault:read',
260 evil: { nested: true },
261 fn: () => {},
262 },
263 });
264 const rec = issued.records[issued.id];
265 assert.equal(rec.meta.ua.length, 256);
266 assert.equal(rec.meta.ip.length, 64);
267 assert.equal(rec.meta.agent.length, 128);
268 assert.equal(rec.meta.client_id, 'cid');
269 assert.equal(rec.meta.scopes, 'vault:read');
270 assert.equal(rec.meta.evil, undefined);
271 assert.equal(rec.meta.fn, undefined);
272 });
273
274 it('rotation preserves agent meta when opts.meta is omitted', () => {
275 const issued = issueToken({}, {
276 sub: SUB,
277 now: T0,
278 meta: { agent: 'Hermes-1', client_id: 'c1', scopes: 'vault:read' },
279 });
280 const r = rotateToken(issued.records, issued.token, { now: T0 + 1 });
281 assert.equal(r.ok, true);
282 assert.equal(r.meta.agent, 'Hermes-1');
283 assert.equal(r.meta.client_id, 'c1');
284 });
285 });
286
287 // ---------------------------------------------------------------------------
288 // 6. performance
289 // ---------------------------------------------------------------------------
290 describe('6. performance — rotation cost under a large store', () => {
291 it('rotates within a tight budget even with 20k records present', () => {
292 const { records, firstToken } = buildLargeStore(20_000, T0);
293 const start = process.hrtime.bigint();
294 const r = rotateToken(records, firstToken, { now: T0 + 1 });
295 const elapsedMs = Number(process.hrtime.bigint() - start) / 1e6;
296 assert.equal(r.ok, true);
297 // Generous CI-safe ceiling; the operation itself is dominated by the clone, not lookup.
298 assert.ok(elapsedMs < 250, `rotation took ${elapsedMs.toFixed(1)}ms, expected < 250ms`);
299 });
300 });
301
302 // ---------------------------------------------------------------------------
303 // 7. security
304 // ---------------------------------------------------------------------------
305 describe('7. security — the parts that must never regress', () => {
306 it('replaying an already-rotated token is detected as REUSE and burns the family', () => {
307 const issued = issueToken({}, { sub: SUB, now: T0 });
308 const r1 = rotateToken(issued.records, issued.token, { now: T0 + 1 });
309 assert.equal(r1.ok, true);
310
311 // Attacker replays the ORIGINAL (already-rotated) token.
312 const replay = rotateToken(r1.records, issued.token, { now: T0 + 2 });
313 assert.equal(replay.ok, false);
314 assert.equal(replay.reason, REFRESH_FAILURE.REUSE);
315
316 // The legitimate successor token is now dead too (whole family revoked).
317 const victim = rotateToken(replay.records, r1.token, { now: T0 + 3 });
318 assert.equal(victim.ok, false);
319 assert.equal(victim.reason, REFRESH_FAILURE.REVOKED);
320 });
321
322 it('a wrong secret for a known id is rejected as INVALID and does not consume the token', () => {
323 const issued = issueToken({}, { sub: SUB, now: T0 });
324 const { id } = parseToken(issued.token);
325 const forged = `${id}.${'A'.repeat(43)}`;
326 const bad = rotateToken(issued.records, forged, { now: T0 + 1 });
327 assert.equal(bad.ok, false);
328 assert.equal(bad.reason, REFRESH_FAILURE.INVALID);
329 // The real token still works (forged attempt must not have rotated/locked it).
330 const good = rotateToken(bad.records, issued.token, { now: T0 + 2 });
331 assert.equal(good.ok, true);
332 });
333
334 it('an unknown id is INVALID', () => {
335 const issued = issueToken({}, { sub: SUB, now: T0 });
336 const res = rotateToken(issued.records, 'unknownid.unknownsecret', { now: T0 + 1 });
337 assert.equal(res.ok, false);
338 assert.equal(res.reason, REFRESH_FAILURE.INVALID);
339 });
340
341 it('a per-token-expired token is EXPIRED and removed', () => {
342 const issued = issueToken({}, { sub: SUB, now: T0, tokenTtlMs: 1000 });
343 const res = rotateToken(issued.records, issued.token, { now: T0 + 2000 });
344 assert.equal(res.ok, false);
345 assert.equal(res.reason, REFRESH_FAILURE.EXPIRED);
346 assert.equal(Object.keys(res.records).length, 0, 'expired record should be pruned on use');
347 });
348
349 it('rotation is refused once the absolute family ceiling passes, even with a fresh token', () => {
350 const issued = issueToken({}, { sub: SUB, now: T0, tokenTtlMs: 10_000, familyTtlMs: 5000 });
351 const res = rotateToken(issued.records, issued.token, { now: T0 + 6000 });
352 assert.equal(res.ok, false);
353 assert.equal(res.reason, REFRESH_FAILURE.EXPIRED);
354 });
355
356 it('revokeToken requires the correct secret — a known id alone cannot evict a session', () => {
357 const issued = issueToken({}, { sub: SUB, now: T0 });
358 const { id } = parseToken(issued.token);
359 const attempt = revokeToken(issued.records, `${id}.wrongsecret`);
360 assert.equal(attempt.revoked, false);
361 assert.equal(Object.keys(attempt.records).length, 1, 'session must survive a wrong-secret revoke');
362 });
363
364 it('revokeAllForSub clears only the targeted user', () => {
365 let r = issueToken({}, { sub: 'google:a', now: T0 });
366 r = issueToken(r.records, { sub: 'google:a', now: T0 });
367 r = issueToken(r.records, { sub: 'google:b', now: T0 });
368 const { records, count } = revokeAllForSub(r.records, 'google:a');
369 assert.equal(count, 2);
370 assert.equal(Object.values(records).every((rec) => rec.sub === 'google:b'), true);
371 });
372
373 it('a revoked token reports REVOKED, not success', () => {
374 const issued = issueToken({}, { sub: SUB, now: T0 });
375 const burned = revokeFamily(issued.records, issued.familyId, T0);
376 const res = rotateToken(burned, issued.token, { now: T0 + 1 });
377 assert.equal(res.ok, false);
378 assert.equal(res.reason, REFRESH_FAILURE.REVOKED);
379 });
380
381 it('pruneExpired removes dead families but keeps live tokens', () => {
382 let r = issueToken({}, { sub: 'google:live', now: T0, familyTtlMs: DEFAULT_FAMILY_TTL_MS });
383 r = issueToken(r.records, { sub: 'google:dead', now: T0, tokenTtlMs: 1000, familyTtlMs: 1000 });
384 const { records, removed } = pruneExpired(r.records, { now: T0 + 2000 });
385 assert.equal(removed, 1);
386 assert.equal(Object.values(records).every((rec) => rec.sub === 'google:live'), true);
387 });
388 });
File History 1 commit
sha256:baa800aedf841dbf32081aa7b2befa288ac33dfc7175ac55014c55c4d8c742f9 docs: move durable-auth freeze/evidence to local developmen… Human 11 days ago