refresh-token-core.test.mjs
365 lines 15.4 KB
Raw
sha256:94ec65bd2b200240ac785a97cf14c5db066832bd608a24d6a9c151f17b918b02 feat(calendar): hosted bridge/gateway route parity and time… Human minor ⚠ breaking 12 hours 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: { ua: 'x'.repeat(1000), ip: 'y'.repeat(200), evil: { nested: true }, fn: () => {} },
255 });
256 const rec = issued.records[issued.id];
257 assert.equal(rec.meta.ua.length, 256);
258 assert.equal(rec.meta.ip.length, 64);
259 assert.equal(rec.meta.evil, undefined);
260 assert.equal(rec.meta.fn, undefined);
261 });
262 });
263
264 // ---------------------------------------------------------------------------
265 // 6. performance
266 // ---------------------------------------------------------------------------
267 describe('6. performance — rotation cost under a large store', () => {
268 it('rotates within a tight budget even with 20k records present', () => {
269 const { records, firstToken } = buildLargeStore(20_000, T0);
270 const start = process.hrtime.bigint();
271 const r = rotateToken(records, firstToken, { now: T0 + 1 });
272 const elapsedMs = Number(process.hrtime.bigint() - start) / 1e6;
273 assert.equal(r.ok, true);
274 // Generous CI-safe ceiling; the operation itself is dominated by the clone, not lookup.
275 assert.ok(elapsedMs < 250, `rotation took ${elapsedMs.toFixed(1)}ms, expected < 250ms`);
276 });
277 });
278
279 // ---------------------------------------------------------------------------
280 // 7. security
281 // ---------------------------------------------------------------------------
282 describe('7. security — the parts that must never regress', () => {
283 it('replaying an already-rotated token is detected as REUSE and burns the family', () => {
284 const issued = issueToken({}, { sub: SUB, now: T0 });
285 const r1 = rotateToken(issued.records, issued.token, { now: T0 + 1 });
286 assert.equal(r1.ok, true);
287
288 // Attacker replays the ORIGINAL (already-rotated) token.
289 const replay = rotateToken(r1.records, issued.token, { now: T0 + 2 });
290 assert.equal(replay.ok, false);
291 assert.equal(replay.reason, REFRESH_FAILURE.REUSE);
292
293 // The legitimate successor token is now dead too (whole family revoked).
294 const victim = rotateToken(replay.records, r1.token, { now: T0 + 3 });
295 assert.equal(victim.ok, false);
296 assert.equal(victim.reason, REFRESH_FAILURE.REVOKED);
297 });
298
299 it('a wrong secret for a known id is rejected as INVALID and does not consume the token', () => {
300 const issued = issueToken({}, { sub: SUB, now: T0 });
301 const { id } = parseToken(issued.token);
302 const forged = `${id}.${'A'.repeat(43)}`;
303 const bad = rotateToken(issued.records, forged, { now: T0 + 1 });
304 assert.equal(bad.ok, false);
305 assert.equal(bad.reason, REFRESH_FAILURE.INVALID);
306 // The real token still works (forged attempt must not have rotated/locked it).
307 const good = rotateToken(bad.records, issued.token, { now: T0 + 2 });
308 assert.equal(good.ok, true);
309 });
310
311 it('an unknown id is INVALID', () => {
312 const issued = issueToken({}, { sub: SUB, now: T0 });
313 const res = rotateToken(issued.records, 'unknownid.unknownsecret', { now: T0 + 1 });
314 assert.equal(res.ok, false);
315 assert.equal(res.reason, REFRESH_FAILURE.INVALID);
316 });
317
318 it('a per-token-expired token is EXPIRED and removed', () => {
319 const issued = issueToken({}, { sub: SUB, now: T0, tokenTtlMs: 1000 });
320 const res = rotateToken(issued.records, issued.token, { now: T0 + 2000 });
321 assert.equal(res.ok, false);
322 assert.equal(res.reason, REFRESH_FAILURE.EXPIRED);
323 assert.equal(Object.keys(res.records).length, 0, 'expired record should be pruned on use');
324 });
325
326 it('rotation is refused once the absolute family ceiling passes, even with a fresh token', () => {
327 const issued = issueToken({}, { sub: SUB, now: T0, tokenTtlMs: 10_000, familyTtlMs: 5000 });
328 const res = rotateToken(issued.records, issued.token, { now: T0 + 6000 });
329 assert.equal(res.ok, false);
330 assert.equal(res.reason, REFRESH_FAILURE.EXPIRED);
331 });
332
333 it('revokeToken requires the correct secret — a known id alone cannot evict a session', () => {
334 const issued = issueToken({}, { sub: SUB, now: T0 });
335 const { id } = parseToken(issued.token);
336 const attempt = revokeToken(issued.records, `${id}.wrongsecret`);
337 assert.equal(attempt.revoked, false);
338 assert.equal(Object.keys(attempt.records).length, 1, 'session must survive a wrong-secret revoke');
339 });
340
341 it('revokeAllForSub clears only the targeted user', () => {
342 let r = issueToken({}, { sub: 'google:a', now: T0 });
343 r = issueToken(r.records, { sub: 'google:a', now: T0 });
344 r = issueToken(r.records, { sub: 'google:b', now: T0 });
345 const { records, count } = revokeAllForSub(r.records, 'google:a');
346 assert.equal(count, 2);
347 assert.equal(Object.values(records).every((rec) => rec.sub === 'google:b'), true);
348 });
349
350 it('a revoked token reports REVOKED, not success', () => {
351 const issued = issueToken({}, { sub: SUB, now: T0 });
352 const burned = revokeFamily(issued.records, issued.familyId, T0);
353 const res = rotateToken(burned, issued.token, { now: T0 + 1 });
354 assert.equal(res.ok, false);
355 assert.equal(res.reason, REFRESH_FAILURE.REVOKED);
356 });
357
358 it('pruneExpired removes dead families but keeps live tokens', () => {
359 let r = issueToken({}, { sub: 'google:live', now: T0, familyTtlMs: DEFAULT_FAMILY_TTL_MS });
360 r = issueToken(r.records, { sub: 'google:dead', now: T0, tokenTtlMs: 1000, familyTtlMs: 1000 });
361 const { records, removed } = pruneExpired(r.records, { now: T0 + 2000 });
362 assert.equal(removed, 1);
363 assert.equal(Object.values(records).every((rec) => rec.sub === 'google:live'), true);
364 });
365 });
File History 1 commit
sha256:94ec65bd2b200240ac785a97cf14c5db066832bd608a24d6a9c151f17b918b02 feat(calendar): hosted bridge/gateway route parity and time… Human minor 12 hours ago