gateway-refresh-token-store.test.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * Hosted gateway refresh-token store — 7-tier suite. |
| 3 | * |
| 4 | * The gateway store is the hosted analogue of the self-hosted file store: it persists to a |
| 5 | * Netlify Blob in production (here a stub) and a local JSON file in dev/test, while delegating |
| 6 | * all security logic to the shared, pure `hub/lib/refresh-token-core.mjs`. These tests prove the |
| 7 | * I/O layer is correct and that the security guarantees (hash-at-rest, rotation, reuse detection, |
| 8 | * revocation, fail-closed corruption handling) survive the round-trip through both backends. |
| 9 | * |
| 10 | * Tiers: unit · integration · end-to-end · stress · data-integrity · performance · security. |
| 11 | */ |
| 12 | |
| 13 | import { describe, it, beforeEach, afterEach } from 'node:test'; |
| 14 | import assert from 'node:assert/strict'; |
| 15 | import fs from 'node:fs'; |
| 16 | import os from 'node:os'; |
| 17 | import path from 'node:path'; |
| 18 | import { |
| 19 | issueRefreshToken, |
| 20 | rotateRefreshToken, |
| 21 | revokeRefreshToken, |
| 22 | revokeAllRefreshTokensForSub, |
| 23 | pruneRefreshTokens, |
| 24 | loadRefreshRecords, |
| 25 | createGatewayRefreshStore, |
| 26 | } from '../hub/gateway/refresh-token-store.mjs'; |
| 27 | |
| 28 | const AUTH_BLOB_GLOBAL = '__knowtation_gateway_auth_blob'; |
| 29 | const BLOB_KEY = 'refresh-tokens-v1'; |
| 30 | |
| 31 | /** In-memory stub mirroring the Netlify Blob `get`/`setJSON` contract used by the store. */ |
| 32 | function createStubBlobStore() { |
| 33 | const map = new Map(); |
| 34 | return { |
| 35 | _map: map, |
| 36 | writes: 0, |
| 37 | async get(key, opts) { |
| 38 | const raw = map.get(key); |
| 39 | if (raw === undefined) return null; |
| 40 | if (opts && opts.type === 'json') return JSON.parse(raw); |
| 41 | return raw; |
| 42 | }, |
| 43 | async setJSON(key, value) { |
| 44 | this.writes += 1; |
| 45 | map.set(key, JSON.stringify(value)); |
| 46 | }, |
| 47 | }; |
| 48 | } |
| 49 | |
| 50 | // --------------------------------------------------------------------------- |
| 51 | // Blob-backed (production path) |
| 52 | // --------------------------------------------------------------------------- |
| 53 | describe('gateway refresh store — blob backend', () => { |
| 54 | let stub; |
| 55 | beforeEach(() => { |
| 56 | stub = createStubBlobStore(); |
| 57 | globalThis[AUTH_BLOB_GLOBAL] = stub; |
| 58 | }); |
| 59 | afterEach(() => { delete globalThis[AUTH_BLOB_GLOBAL]; }); |
| 60 | |
| 61 | // -------- unit -------- |
| 62 | it('unit: issue returns a "<id>.<secret>" token and persists under the tokens key', async () => { |
| 63 | const { token, id, familyId } = await issueRefreshToken('google:1', { now: 1000 }); |
| 64 | assert.ok(token.includes('.'), 'token is id.secret'); |
| 65 | assert.ok(id && familyId); |
| 66 | const persisted = await stub.get(BLOB_KEY, { type: 'json' }); |
| 67 | assert.ok(persisted.tokens[id], 'record stored by id'); |
| 68 | assert.equal(persisted.tokens[id].sub, 'google:1'); |
| 69 | }); |
| 70 | |
| 71 | // -------- integration -------- |
| 72 | it('integration: a rotated token yields a new token and the old one is consumed', async () => { |
| 73 | const first = await issueRefreshToken('google:1', { now: 1000 }); |
| 74 | const rotated = await rotateRefreshToken(first.token, { now: 2000 }); |
| 75 | assert.equal(rotated.ok, true); |
| 76 | assert.notEqual(rotated.token, first.token); |
| 77 | assert.equal(rotated.sub, 'google:1'); |
| 78 | }); |
| 79 | |
| 80 | // -------- end-to-end / lifecycle -------- |
| 81 | it('e2e: issue → rotate×3 → logout → rotate fails (full session lifecycle)', async () => { |
| 82 | let cur = (await issueRefreshToken('github:42', { now: 1000 })).token; |
| 83 | for (let i = 0; i < 3; i++) { |
| 84 | const r = await rotateRefreshToken(cur, { now: 2000 + i }); |
| 85 | assert.equal(r.ok, true); |
| 86 | cur = r.token; |
| 87 | } |
| 88 | const out = await revokeRefreshToken(cur); |
| 89 | assert.equal(out.revoked, true); |
| 90 | const afterLogout = await rotateRefreshToken(cur, { now: 9000 }); |
| 91 | assert.equal(afterLogout.ok, false); |
| 92 | }); |
| 93 | |
| 94 | // -------- stress -------- |
| 95 | it('stress: 200 independent sessions issue + rotate without cross-contamination', async () => { |
| 96 | const live = []; |
| 97 | for (let i = 0; i < 200; i++) { |
| 98 | const { token } = await issueRefreshToken(`google:${i}`, { now: 1000 }); |
| 99 | live.push({ i, token }); |
| 100 | } |
| 101 | for (const s of live) { |
| 102 | const r = await rotateRefreshToken(s.token, { now: 2000 }); |
| 103 | assert.equal(r.ok, true); |
| 104 | assert.equal(r.sub, `google:${s.i}`, 'each rotation returns its own subject'); |
| 105 | } |
| 106 | const persisted = await stub.get(BLOB_KEY, { type: 'json' }); |
| 107 | // 200 fresh successors live + 200 consumed tombstones retained for reuse detection. |
| 108 | assert.equal(Object.keys(persisted.tokens).length, 400); |
| 109 | }); |
| 110 | |
| 111 | // -------- data-integrity -------- |
| 112 | it('data-integrity: a foreign/corrupt blob payload normalizes to an empty store (fail-closed)', async () => { |
| 113 | stub._map.set(BLOB_KEY, JSON.stringify({ tokens: { junk: { not: 'a token' }, '': null }, garbage: 1 })); |
| 114 | const records = await loadRefreshRecords(); |
| 115 | assert.deepEqual(records, {}, 'only well-formed records survive; nothing throws'); |
| 116 | }); |
| 117 | |
| 118 | it('data-integrity: the raw secret is never written to the store (only its hash)', async () => { |
| 119 | const { token, id } = await issueRefreshToken('google:1', { now: 1000 }); |
| 120 | const secret = token.slice(token.indexOf('.') + 1); |
| 121 | const persistedRaw = stub._map.get(BLOB_KEY); |
| 122 | assert.ok(!persistedRaw.includes(secret), 'raw secret must not appear in persisted JSON'); |
| 123 | const rec = JSON.parse(persistedRaw).tokens[id]; |
| 124 | assert.ok(typeof rec.token_hash === 'string' && rec.token_hash.length > 0); |
| 125 | assert.equal(rec.secret, undefined); |
| 126 | }); |
| 127 | |
| 128 | // -------- performance -------- |
| 129 | it('performance: 300 issue+rotate cycles complete well under budget', async () => { |
| 130 | const t0 = Date.now(); |
| 131 | for (let i = 0; i < 300; i++) { |
| 132 | const { token } = await issueRefreshToken(`u:${i}`, { now: 1000 }); |
| 133 | await rotateRefreshToken(token, { now: 1001 }); |
| 134 | } |
| 135 | const elapsed = Date.now() - t0; |
| 136 | assert.ok(elapsed < 5000, `expected < 5s, took ${elapsed}ms`); |
| 137 | }); |
| 138 | |
| 139 | // -------- security -------- |
| 140 | it('security: replaying a rotated token is detected as reuse and burns the whole family', async () => { |
| 141 | const first = await issueRefreshToken('google:1', { now: 1000 }); |
| 142 | const rotated = await rotateRefreshToken(first.token, { now: 2000 }); |
| 143 | assert.equal(rotated.ok, true); |
| 144 | // Replay the original (already-rotated) token. |
| 145 | const replay = await rotateRefreshToken(first.token, { now: 3000 }); |
| 146 | assert.equal(replay.ok, false); |
| 147 | assert.equal(replay.reason, 'reuse'); |
| 148 | // The successor minted from the stolen family is now dead too. |
| 149 | const successorAfterBurn = await rotateRefreshToken(rotated.token, { now: 4000 }); |
| 150 | assert.equal(successorAfterBurn.ok, false); |
| 151 | }); |
| 152 | |
| 153 | it('security: revokeAllRefreshTokensForSub signs out every session for a user', async () => { |
| 154 | const a = await issueRefreshToken('google:1', { now: 1000 }); |
| 155 | const b = await issueRefreshToken('google:1', { now: 1000 }); |
| 156 | const { count } = await revokeAllRefreshTokensForSub('google:1'); |
| 157 | assert.equal(count, 2); |
| 158 | assert.equal((await rotateRefreshToken(a.token, { now: 2000 })).ok, false); |
| 159 | assert.equal((await rotateRefreshToken(b.token, { now: 2000 })).ok, false); |
| 160 | }); |
| 161 | |
| 162 | it('security: read-after-write — a rotation observes its own consume immediately', async () => { |
| 163 | // In prod the blob is eventual-consistency (strong is unavailable in Lambda-compat mode), so |
| 164 | // read-after-write within a single invocation is exercised here with a synchronous stub: it |
| 165 | // proves the store reads back the state it just wrote before the next decision. Cross-edge |
| 166 | // propagation (≤60s) is the documented residual window — see refresh-token-store.mjs. |
| 167 | const first = await issueRefreshToken('google:1', { now: 1000 }); |
| 168 | await rotateRefreshToken(first.token, { now: 2000 }); |
| 169 | const persisted = await stub.get(BLOB_KEY, { type: 'json' }); |
| 170 | const id = first.token.slice(0, first.token.indexOf('.')); |
| 171 | assert.ok(persisted.tokens[id].rotated_to, 'old record carries a rotated_to tombstone'); |
| 172 | }); |
| 173 | |
| 174 | it('createGatewayRefreshStore exposes a working async { issue, rotate, revoke } adapter', async () => { |
| 175 | const store = createGatewayRefreshStore(); |
| 176 | const { token } = await store.issue('google:1', { now: 1000 }); |
| 177 | const r = await store.rotate(token, { now: 2000 }); |
| 178 | assert.equal(r.ok, true); |
| 179 | const out = await store.revoke(r.token); |
| 180 | assert.equal(out.revoked, true); |
| 181 | }); |
| 182 | }); |
| 183 | |
| 184 | // --------------------------------------------------------------------------- |
| 185 | // File-backed (dev / persistent-gateway fallback) |
| 186 | // --------------------------------------------------------------------------- |
| 187 | describe('gateway refresh store — file backend', () => { |
| 188 | let dir, savedEnv; |
| 189 | beforeEach(() => { |
| 190 | delete globalThis[AUTH_BLOB_GLOBAL]; // force file path |
| 191 | dir = fs.mkdtempSync(path.join(os.tmpdir(), 'knowtation-gw-refresh-')); |
| 192 | savedEnv = process.env.KNOWTATION_GATEWAY_DATA_DIR; |
| 193 | process.env.KNOWTATION_GATEWAY_DATA_DIR = dir; |
| 194 | }); |
| 195 | afterEach(() => { |
| 196 | if (savedEnv === undefined) delete process.env.KNOWTATION_GATEWAY_DATA_DIR; |
| 197 | else process.env.KNOWTATION_GATEWAY_DATA_DIR = savedEnv; |
| 198 | try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* noop */ } |
| 199 | }); |
| 200 | |
| 201 | it('integration: issue persists a JSON file that rotate can read back', async () => { |
| 202 | const { token } = await issueRefreshToken('google:1', { now: 1000 }); |
| 203 | const file = path.join(dir, 'hosted_refresh_tokens.json'); |
| 204 | assert.ok(fs.existsSync(file), 'store file written'); |
| 205 | const parsed = JSON.parse(fs.readFileSync(file, 'utf8')); |
| 206 | assert.ok(parsed.tokens && Object.keys(parsed.tokens).length === 1); |
| 207 | const r = await rotateRefreshToken(token, { now: 2000 }); |
| 208 | assert.equal(r.ok, true); |
| 209 | }); |
| 210 | |
| 211 | it('data-integrity: a missing store file loads as empty (no throw)', async () => { |
| 212 | const records = await loadRefreshRecords(); |
| 213 | assert.deepEqual(records, {}); |
| 214 | }); |
| 215 | |
| 216 | it('data-integrity: a corrupt store file fails closed to empty', async () => { |
| 217 | fs.writeFileSync(path.join(dir, 'hosted_refresh_tokens.json'), '{ this is not json', 'utf8'); |
| 218 | const records = await loadRefreshRecords(); |
| 219 | assert.deepEqual(records, {}); |
| 220 | }); |
| 221 | |
| 222 | it('prune removes dead records once the family lifetime has elapsed', async () => { |
| 223 | await issueRefreshToken('google:1', { now: 1000, tokenTtlMs: 10, familyTtlMs: 10 }); |
| 224 | const { removed } = await pruneRefreshTokens({ now: 10_000_000 }); |
| 225 | assert.ok(removed >= 1); |
| 226 | }); |
| 227 | }); |
File History
2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠
1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6
docs: accept Calendar Events v0 spec with Phase 0 security …
Human
2 days ago