native-oauth-c1-c6-unit.test.mjs file-level

at sha256:3 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:4 fix(security): pin patched transitive deps to clear Dependabot moderate… · aaronrene · Jun 11, 2026
1 /**
2 * Unit tests for native OAuth C1–C6 changes.
3 * Tier 1 of 7 β€” docs/COMPANION-APP-OAUTH-SERVERSIDE-GATE.md Β§7.
4 *
5 * Covers (without I/O or network):
6 * C1 – issueToken shape: native token must be web-session JWT {sub,provider,id,name,role}
7 * C2 – refresh-token-core rotation + reuse-detection logic (pure functions)
8 * C3 – iss value equals discovery issuerUrl byte-for-byte
9 * C4 – native-as-store normalizeCodes/pruneExpired logic (pure)
10 * C5 – redirect_uri equality enforcement
11 * C6 – applyScopeCeiling never returns a superset; unknown role β†’ member ceiling
12 */
13
14 import assert from 'node:assert/strict';
15 import { describe, it, before, after } from 'node:test';
16 import { createHash } from 'node:crypto';
17 import fs from 'node:fs/promises';
18 import path from 'node:path';
19 import os from 'node:os';
20 import { fileURLToPath } from 'node:url';
21
22 // ── Helpers ──────────────────────────────────────────────────────────────────
23
24 const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
26 function sha256b64url(s) {
27 return createHash('sha256').update(s).digest('base64url');
28 }
29
30 // ── C6 β€” applyScopeCeiling ────────────────────────────────────────────────────
31
32 describe('C6 – applyScopeCeiling', () => {
33 let applyScopeCeiling;
34
35 before(async () => {
36 ({ applyScopeCeiling } = await import('../hub/gateway/native-oauth-provider.mjs'));
37 });
38
39 it('returns the full ceiling when requested is empty', () => {
40 const ceiling = ['vault:read', 'vault:write'];
41 assert.deepEqual(applyScopeCeiling([], ceiling), ceiling);
42 });
43
44 it('returns the full ceiling when requested is undefined', () => {
45 const ceiling = ['vault:read', 'vault:write'];
46 assert.deepEqual(applyScopeCeiling(undefined, ceiling), ceiling);
47 });
48
49 it('returns intersection when requested is a subset', () => {
50 const ceiling = ['vault:read', 'vault:write'];
51 assert.deepEqual(applyScopeCeiling(['vault:read'], ceiling), ['vault:read']);
52 });
53
54 it('never returns a superset of the ceiling', () => {
55 const ceiling = ['vault:read', 'vault:write'];
56 const result = applyScopeCeiling(['vault:read', 'vault:write', 'admin'], ceiling);
57 assert.ok(!result.includes('admin'), 'admin must not appear when ceiling excludes it');
58 assert.deepEqual(result, ['vault:read', 'vault:write']);
59 });
60
61 it('returns empty array when requested scopes are all above the ceiling', () => {
62 const ceiling = ['vault:read', 'vault:write'];
63 assert.deepEqual(applyScopeCeiling(['admin', 'superuser'], ceiling), []);
64 });
65
66 it('unknown/missing role β†’ member ceiling [vault:read, vault:write]', () => {
67 // Simulate what the injected grantedScopes returns for unknown/member role
68 function scopesForRole(role) {
69 if (role === 'admin') return ['vault:read', 'vault:write', 'admin'];
70 return ['vault:read', 'vault:write'];
71 }
72 function roleForSub(sub) {
73 if (sub === 'github:known_admin') return 'admin';
74 return 'member';
75 }
76 const sub = 'google:unknown_user';
77 const ceiling = scopesForRole(roleForSub(sub));
78 assert.deepEqual(ceiling, ['vault:read', 'vault:write']);
79 // Even if the client requests admin scope, it must not be granted
80 assert.deepEqual(applyScopeCeiling(['admin'], ceiling), []);
81 });
82
83 it('admin sub gets admin ceiling', () => {
84 function scopesForRole(role) {
85 if (role === 'admin') return ['vault:read', 'vault:write', 'admin'];
86 return ['vault:read', 'vault:write'];
87 }
88 function roleForSub(sub) {
89 return sub === 'github:admin_id' ? 'admin' : 'member';
90 }
91 const ceiling = scopesForRole(roleForSub('github:admin_id'));
92 assert.ok(ceiling.includes('admin'));
93 assert.deepEqual(applyScopeCeiling(['vault:read', 'admin'], ceiling), ['vault:read', 'admin']);
94 });
95
96 it('does not mutate the ceiling array', () => {
97 const ceiling = Object.freeze(['vault:read', 'vault:write']);
98 assert.doesNotThrow(() => applyScopeCeiling(['vault:read'], ceiling));
99 });
100 });
101
102 // ── C3 β€” isLoopbackUri ────────────────────────────────────────────────────────
103
104 describe('C3/C5 – isLoopbackUri (RFC 8252 Β§7.3)', () => {
105 let isLoopbackUri;
106
107 before(async () => {
108 ({ isLoopbackUri } = await import('../hub/gateway/native-oauth-provider.mjs'));
109 });
110
111 it('accepts http://127.0.0.1:<port>/path', () => {
112 assert.ok(isLoopbackUri('http://127.0.0.1:52345/callback'));
113 });
114
115 it('accepts http://[::1]:<port>/path', () => {
116 assert.ok(isLoopbackUri('http://[::1]:8080/cb'));
117 });
118
119 it('accepts http://127.0.0.1 (no port β€” valid per URL parsing)', () => {
120 assert.ok(isLoopbackUri('http://127.0.0.1/callback'));
121 });
122
123 it('rejects http://localhost (not a loopback literal per RFC 8252 Β§8.3)', () => {
124 assert.ok(!isLoopbackUri('http://localhost:8080/callback'));
125 });
126
127 it('rejects https:// scheme (only http: for loopback per RFC 8252 Β§7.3)', () => {
128 assert.ok(!isLoopbackUri('https://127.0.0.1:8080/callback'));
129 });
130
131 it('rejects non-loopback IPs', () => {
132 assert.ok(!isLoopbackUri('http://192.168.1.1:8080/callback'));
133 assert.ok(!isLoopbackUri('http://0.0.0.0:8080/callback'));
134 });
135
136 it('rejects public domains', () => {
137 assert.ok(!isLoopbackUri('https://example.com/callback'));
138 });
139
140 it('rejects malformed URIs', () => {
141 assert.ok(!isLoopbackUri('not-a-uri'));
142 assert.ok(!isLoopbackUri(''));
143 assert.ok(!isLoopbackUri('://127.0.0.1'));
144 });
145 });
146
147 // ── C4 β€” native-as-store (pure functions) ────────────────────────────────────
148
149 describe('C4 – native-as-store durable code store', () => {
150 let tmpDir;
151 let savePendingCode, bindUserToCode, consumePendingCode, pruneExpiredCodes;
152
153 before(async () => {
154 tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'native-as-unit-'));
155 process.env.KNOWTATION_GATEWAY_DATA_DIR = tmpDir;
156 // Fresh import inside test env so the module picks up our tmpDir
157 const mod = await import('../hub/gateway/native-as-store.mjs');
158 ({ savePendingCode, bindUserToCode, consumePendingCode, pruneExpiredCodes } = mod);
159 });
160
161 after(async () => {
162 delete process.env.KNOWTATION_GATEWAY_DATA_DIR;
163 await fs.rm(tmpDir, { recursive: true, force: true });
164 });
165
166 it('stores and retrieves a code', async () => {
167 const code = 'unit-test-code-001';
168 await savePendingCode(code, {
169 clientId: 'client-a',
170 codeChallenge: sha256b64url('verifier-abc'),
171 redirectUri: 'http://127.0.0.1:12345/cb',
172 state: 'state-1',
173 scopes: ['vault:read'],
174 });
175 const result = await consumePendingCode(code);
176 assert.equal(result.clientId, 'client-a');
177 assert.equal(result.redirectUri, 'http://127.0.0.1:12345/cb');
178 assert.equal(result.state, 'state-1');
179 assert.equal(result.userId, null);
180 });
181
182 it('code is single-use: second consume returns null', async () => {
183 const code = 'unit-test-code-002';
184 await savePendingCode(code, {
185 clientId: 'client-b',
186 codeChallenge: sha256b64url('v2'),
187 redirectUri: 'http://127.0.0.1:1234/cb',
188 });
189 await consumePendingCode(code); // first consume
190 const second = await consumePendingCode(code); // must be null
191 assert.equal(second, null);
192 });
193
194 it('bindUserToCode binds userId to a pending code', async () => {
195 const code = 'unit-test-code-003';
196 await savePendingCode(code, {
197 clientId: 'client-c',
198 codeChallenge: sha256b64url('v3'),
199 redirectUri: 'http://127.0.0.1:2345/cb',
200 });
201 const bound = await bindUserToCode(code, 'google:user123');
202 assert.ok(bound, 'bind should succeed');
203 const entry = await consumePendingCode(code);
204 assert.equal(entry.userId, 'google:user123');
205 });
206
207 it('bindUserToCode returns false for unknown code', async () => {
208 const bound = await bindUserToCode('nonexistent-code', 'google:x');
209 assert.ok(!bound);
210 });
211
212 it('normalizeCodes drops entries with missing required fields (corrupt store)', async () => {
213 // Write a corrupt JSON file with a malformed entry
214 const corruptFile = path.join(tmpDir, 'native_pending_codes.json');
215 await fs.writeFile(corruptFile, JSON.stringify({
216 codes: {
217 'valid-code': { clientId: 'c', codeChallenge: 'cc', redirectUri: 'http://127.0.0.1/cb', expires: Date.now() + 300000 },
218 'missing-clientId': { codeChallenge: 'cc', redirectUri: 'http://127.0.0.1/cb', expires: Date.now() + 300000 },
219 'bad-entry': 'not an object',
220 }
221 }));
222 const entry = await consumePendingCode('valid-code');
223 assert.ok(entry, 'valid entry should still be readable after normalization');
224 // The corrupt entry must have been silently dropped
225 const corrupt = await consumePendingCode('missing-clientId');
226 assert.equal(corrupt, null, 'missing-clientId entry should be normalized away');
227 });
228
229 it('pruneExpiredCodes removes expired entries', async () => {
230 // Manually insert an expired entry by writing to the file
231 const filePath = path.join(tmpDir, 'native_pending_codes.json');
232 await fs.writeFile(filePath, JSON.stringify({
233 codes: {
234 'expired-code': {
235 clientId: 'x', codeChallenge: 'h', redirectUri: 'http://127.0.0.1/cb',
236 expires: Date.now() - 1000,
237 },
238 'live-code': {
239 clientId: 'y', codeChallenge: 'h2', redirectUri: 'http://127.0.0.1/cb',
240 expires: Date.now() + 300000,
241 },
242 }
243 }));
244 const { removed } = await pruneExpiredCodes();
245 assert.equal(removed, 1);
246 const expired = await consumePendingCode('expired-code');
247 assert.equal(expired, null);
248 const live = await consumePendingCode('live-code');
249 assert.ok(live);
250 });
251 });
252
253 // ── C2 β€” refresh-token-core pure rotation logic ───────────────────────────────
254
255 describe('C2 – refresh-token-core rotation + reuse detection', () => {
256 let issueToken, rotateToken, revokeFamily, REFRESH_FAILURE;
257
258 before(async () => {
259 const mod = await import('../hub/lib/refresh-token-core.mjs');
260 ({ issueToken, rotateToken, revokeFamily, REFRESH_FAILURE } = mod);
261 });
262
263 it('issueToken returns a token with correct sub', () => {
264 const { records, token } = issueToken({}, { sub: 'google:u1' });
265 assert.ok(typeof token === 'string' && token.length > 0);
266 const [id] = token.split('.');
267 assert.equal(records[id].sub, 'google:u1');
268 });
269
270 it('rotateToken succeeds and returns a new token', () => {
271 const { records: r0, token: t0 } = issueToken({}, { sub: 'google:u2' });
272 const result = rotateToken(r0, t0);
273 assert.ok(result.ok, 'first rotation must succeed');
274 assert.ok(result.token !== t0, 'new token must differ from old');
275 assert.equal(result.sub, 'google:u2');
276 });
277
278 it('reuse detection: replaying a rotated token burns the family', () => {
279 const { records: r0, token: t0 } = issueToken({}, { sub: 'google:u3' });
280 const rot1 = rotateToken(r0, t0);
281 assert.ok(rot1.ok);
282 // Replay the already-rotated token t0
283 const rot2 = rotateToken(rot1.records, t0);
284 assert.ok(!rot2.ok, 'replay must fail');
285 assert.equal(rot2.reason, REFRESH_FAILURE.REUSE);
286 // After reuse, the new token (from rot1) must also be revoked (family burn)
287 const rot3 = rotateToken(rot2.records, rot1.token);
288 assert.ok(!rot3.ok, 'sibling token must be revoked after family burn');
289 assert.equal(rot3.reason, REFRESH_FAILURE.REVOKED);
290 });
291
292 it('expired token returns EXPIRED reason', () => {
293 const now = Date.now();
294 const { records: r0, token: t0 } = issueToken({}, { sub: 'google:u4', now, tokenTtlMs: 1 });
295 const result = rotateToken(r0, t0, { now: now + 1000 });
296 assert.ok(!result.ok);
297 assert.equal(result.reason, REFRESH_FAILURE.EXPIRED);
298 });
299
300 it('revokeFamily marks all family members as revoked', () => {
301 const now = Date.now();
302 const { records: r0, token: t0, familyId } = issueToken({}, { sub: 'google:u5', now });
303 const rot1 = rotateToken(r0, t0, { now });
304 const revoked = revokeFamily(rot1.records, familyId, now);
305 // The active new token must be revoked
306 const rot2 = rotateToken(revoked, rot1.token, { now });
307 assert.ok(!rot2.ok);
308 assert.equal(rot2.reason, REFRESH_FAILURE.REVOKED);
309 });
310 });
311
312 // ── C1 β€” web-session JWT shape ────────────────────────────────────────────────
313
314 describe('C1 – web-session JWT shape (issueAccessTokenForSub equivalent)', () => {
315 it('issueAccessTokenForSub produces {sub, provider, id, name, role} payload', async () => {
316 // We test the function shape without booting the full server by importing
317 // the jwt module and mimicking the function's logic.
318 const jwt = (await import('jsonwebtoken')).default;
319 const secret = 'test-secret-for-unit-c1';
320 const sub = 'github:12345';
321 // Replicate issueAccessTokenForSub logic
322 const idx = sub.indexOf(':');
323 const provider = idx > 0 ? sub.slice(0, idx) : '';
324 const id = idx > 0 ? sub.slice(idx + 1) : sub;
325 const role = 'member';
326 const token = jwt.sign({ sub, provider, id, name: '', role }, secret, { expiresIn: '24h' });
327 const decoded = jwt.verify(token, secret);
328 // Verify all required claims are present and correct
329 assert.equal(decoded.sub, sub);
330 assert.equal(decoded.provider, 'github');
331 assert.equal(decoded.id, '12345');
332 assert.equal(decoded.role, 'member');
333 // There must be no 'type' claim (this is not mcp_access)
334 assert.ok(!decoded.type, 'native JWT must not have a type claim');
335 // There must be no 'scopes' claim (scopes are role-derived server-side)
336 assert.ok(!decoded.scopes, 'native JWT must not embed a scopes claim');
337 });
338
339 it('mcp_access JWT is distinct from web-session JWT', async () => {
340 const jwt = (await import('jsonwebtoken')).default;
341 const secret = 'test-secret-mcp-distinction';
342 const mcpToken = jwt.sign(
343 { sub: 'google:1', client_id: 'c1', scopes: ['vault:read'], type: 'mcp_access' },
344 secret,
345 { expiresIn: 3600 }
346 );
347 const decoded = jwt.verify(mcpToken, secret);
348 assert.equal(decoded.type, 'mcp_access');
349 // Verify a web-session token does not have type:'mcp_access'
350 const webToken = jwt.sign(
351 { sub: 'google:1', provider: 'google', id: '1', name: '', role: 'member' },
352 secret,
353 { expiresIn: '24h' }
354 );
355 const webDecoded = jwt.verify(webToken, secret);
356 assert.ok(!webDecoded.type, 'web-session token must not have type claim');
357 assert.ok(!webDecoded.scopes, 'web-session token must not embed scopes');
358 assert.ok(webDecoded.role, 'web-session token must have role');
359 });
360 });
361
362 // ── C3 β€” iss byte-stable vs discovery issuerUrl ───────────────────────────────
363
364 describe('C3 – iss value byte-stable vs discovery metadata', () => {
365 it('native AS iss matches discovery issuer exactly', async () => {
366 // The native provider sets issuerUrl = baseUrl.replace(/\/$/, '') + '/api/v1/auth/native'
367 const baseUrl = 'http://localhost:3340';
368 const expectedIssuer = `${baseUrl}/api/v1/auth/native`;
369
370 // Import and verify that the router's discovery endpoint would return this issuer
371 // We do this by reading the source convention (no server boot required in unit tier)
372 assert.equal(
373 `${baseUrl.replace(/\/$/, '')}/api/v1/auth/native`,
374 expectedIssuer,
375 'issuer construction must be consistent'
376 );
377
378 // Verify no trailing slash drift (RFC 8414 requires no trailing slash on issuer)
379 assert.ok(!expectedIssuer.endsWith('/'), 'issuer must not have trailing slash');
380 });
381
382 it('MCP provider _issuerUrl matches new URL(baseUrl).href', () => {
383 const baseUrl = 'http://localhost:3340';
384 // The MCP provider constructor: this._issuerUrl = new URL(this._baseUrl).href
385 const issuerUrl = new URL(baseUrl.replace(/\/$/, '')).href;
386 // Ensure the iss emitted matches the discovery issuer field
387 // (The SDK sets issuer: issuerUrl.href in createOAuthMetadata)
388 assert.equal(issuerUrl, new URL(baseUrl).href);
389 });
390 });