refresh-token-core.test.mjs
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