gateway-refresh-token-store.test.mjs
227 lines 10.0 KB
Raw
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