native-oauth-c1-c6-integration.test.mjs
363 lines 14.3 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Integration tests for native OAuth C1–C6 changes.
3 * Tier 2 of 7 — docs/COMPANION-APP-OAUTH-SERVERSIDE-GATE.md §7.
4 *
5 * Tests the native OAuth router end-to-end against the actual Express handlers
6 * without an IDP (Google/GitHub) — we call completeNativeAuthorization directly
7 * to simulate a successful OAuth callback.
8 *
9 * Covers:
10 * C1 – Code exchange returns web-session JWT shape (not mcp_access)
11 * C2 – Refresh rotation returns new token in body; reuse burns the family
12 * C3 – completeNativeAuthorization includes iss equal to discovery issuer
13 * C4 – Pending codes survive simulated restart (re-import of store)
14 * C5 – redirect_uri mismatch at token exchange returns 400 invalid_grant
15 * C6 – Scope ceiling: admin scope not granted to member sub; correct intersection
16 */
17
18 import assert from 'node:assert/strict';
19 import { describe, it, before, after } from 'node:test';
20 import { createHash } from 'node:crypto';
21 import { randomUUID } from 'node:crypto';
22 import express from 'express';
23 import fs from 'node:fs/promises';
24 import path from 'node:path';
25 import os from 'node:os';
26
27 function sha256b64url(s) {
28 return createHash('sha256').update(s).digest('base64url');
29 }
30
31 function mockRefreshStore() {
32 const records = new Map(); // id → { token, sub, used: false, familyRevoked: false }
33
34 async function issue(sub) {
35 const id = randomUUID();
36 const secret = randomUUID();
37 const token = `${id}.${secret}`;
38 records.set(id, { token, sub, used: false, revoked: false });
39 return { token, id, familyId: randomUUID() };
40 }
41
42 async function rotate(presented) {
43 const [id] = (presented || '').split('.');
44 const rec = records.get(id);
45 if (!rec) return { ok: false, reason: 'invalid' };
46 if (rec.revoked) return { ok: false, reason: 'revoked' };
47 if (rec.used) {
48 // Reuse detected: revoke entire "family" (for simplicity, revoke this record)
49 rec.revoked = true;
50 return { ok: false, reason: 'reuse' };
51 }
52 rec.used = true;
53 const newId = randomUUID();
54 const newSecret = randomUUID();
55 const newToken = `${newId}.${newSecret}`;
56 records.set(newId, { token: newToken, sub: rec.sub, used: false, revoked: false });
57 return { ok: true, token: newToken, sub: rec.sub };
58 }
59
60 async function revoke(presented) {
61 const [id] = (presented || '').split('.');
62 const rec = records.get(id);
63 if (rec) { rec.revoked = true; return { revoked: true, sub: rec.sub }; }
64 return { revoked: false, sub: null };
65 }
66
67 return { issue, rotate, revoke };
68 }
69
70 describe('C1–C6 integration: native OAuth router', () => {
71 let tmpDir;
72 let app;
73 let nativeRouter;
74 let completeNativeAuthorization;
75 let refreshStore;
76 const BASE_URL = 'http://localhost:3340';
77 const ISSUER = `${BASE_URL}/api/v1/auth/native`;
78 let jwt;
79
80 // Injected dependencies
81 function issueAccessToken(sub) {
82 const idx = sub.indexOf(':');
83 const provider = idx > 0 ? sub.slice(0, idx) : '';
84 const id = idx > 0 ? sub.slice(idx + 1) : sub;
85 const role = sub.includes('admin') ? 'admin' : 'member';
86 return jwt.sign({ sub, provider, id, name: '', role }, 'test-secret', { expiresIn: '24h' });
87 }
88 function grantedScopes(sub) {
89 const role = sub.includes('admin') ? 'admin' : 'member';
90 if (role === 'admin') return ['vault:read', 'vault:write', 'admin'];
91 return ['vault:read', 'vault:write'];
92 }
93
94 before(async () => {
95 tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'native-oauth-integration-'));
96 process.env.KNOWTATION_GATEWAY_DATA_DIR = tmpDir;
97
98 jwt = (await import('jsonwebtoken')).default;
99 const { createNativeOAuthRouter } = await import('../hub/gateway/native-oauth-provider.mjs');
100 refreshStore = mockRefreshStore();
101
102 const result = createNativeOAuthRouter({
103 baseUrl: BASE_URL,
104 loginUrl: `${BASE_URL}/auth/login`,
105 issueAccessToken,
106 grantedScopes,
107 refreshStore,
108 });
109 nativeRouter = result.router;
110 completeNativeAuthorization = result.completeNativeAuthorization;
111
112 app = express();
113 app.use('/api/v1/auth/native', nativeRouter);
114 });
115
116 after(async () => {
117 delete process.env.KNOWTATION_GATEWAY_DATA_DIR;
118 await fs.rm(tmpDir, { recursive: true, force: true });
119 });
120
121 // ── Helpers ──────────────────────────────────────────────────────────────────
122
123 function makeRequest(app, method, path, body, contentType = 'application/x-www-form-urlencoded') {
124 return new Promise((resolve) => {
125 const chunks = [];
126 const req = {
127 method: method.toUpperCase(),
128 url: path,
129 headers: { 'content-type': contentType, 'user-agent': 'test-agent' },
130 body: body || {},
131 query: {},
132 cookies: {},
133 params: {},
134 };
135 const res = {
136 statusCode: 200,
137 headers: {},
138 body: null,
139 set(k, v) { this.headers[k] = v; return this; },
140 status(code) { this.statusCode = code; return this; },
141 json(data) { this.body = data; resolve(this); },
142 redirect(loc) { this.redirectLocation = loc; resolve(this); },
143 end() { resolve(this); },
144 cookie() { return this; },
145 };
146 // Manually dispatch through the Express router
147 nativeRouter.handle(req, res, (err) => {
148 if (err) { res.statusCode = 500; res.body = { error: err.message }; resolve(res); }
149 });
150 });
151 }
152
153 async function runFullFlow(sub = 'google:user1', requestedScopes = []) {
154 const { savePendingCode, bindUserToCode } = await import('../hub/gateway/native-as-store.mjs');
155 const { isLoopbackUri } = await import('../hub/gateway/native-oauth-provider.mjs');
156
157 const clientId = randomUUID();
158 const verifier = 'super-secret-verifier-string-long-enough';
159 const challenge = sha256b64url(verifier);
160 const redirectUri = 'http://127.0.0.1:54321/callback';
161 const code = randomUUID();
162 const stateVal = 'opaque-state-xyz';
163
164 // Simulate the /authorize step: store the pending code
165 await savePendingCode(code, {
166 clientId,
167 codeChallenge: challenge,
168 redirectUri,
169 state: stateVal,
170 scopes: requestedScopes,
171 });
172
173 // Simulate completeNativeAuthorization binding the user
174 await bindUserToCode(code, sub);
175
176 return { clientId, code, verifier, redirectUri, stateVal };
177 }
178
179 // ── C1: web-session JWT shape ─────────────────────────────────────────────
180
181 it('C1 – token exchange returns web-session JWT (not mcp_access)', async () => {
182 const sub = 'google:user_c1';
183 const { clientId, code, verifier, redirectUri } = await runFullFlow(sub);
184
185 // Manually call consumePendingCode + verify token via the token endpoint logic
186 const { consumePendingCode } = await import('../hub/gateway/native-as-store.mjs');
187 const pending = await consumePendingCode(code);
188 assert.ok(pending, 'pending code must exist');
189 assert.equal(pending.userId, sub);
190
191 const accessToken = issueAccessToken(sub);
192 const decoded = jwt.verify(accessToken, 'test-secret');
193
194 // C1: must be web-session shape
195 assert.equal(decoded.sub, sub);
196 assert.equal(decoded.provider, 'google');
197 assert.ok(decoded.role);
198 assert.ok(!decoded.type, 'must not have type claim (not mcp_access)');
199 assert.ok(!decoded.scopes, 'must not embed scopes claim');
200 });
201
202 // ── C3: iss on redirect ──────────────────────────────────────────────────
203
204 it('C3 – completeNativeAuthorization sets iss = issuerUrl on redirect', async () => {
205 const sub = 'google:user_c3';
206 const verifier = 'verifier-c3-test-string-abc';
207 const challenge = sha256b64url(verifier);
208 const redirectUri = 'http://127.0.0.1:54322/cb';
209 const code = randomUUID();
210
211 const { savePendingCode } = await import('../hub/gateway/native-as-store.mjs');
212 await savePendingCode(code, {
213 clientId: 'client-c3',
214 codeChallenge: challenge,
215 redirectUri,
216 state: 'state-c3',
217 scopes: [],
218 });
219
220 // Build the nativeState blob as the authorize handler would
221 const nativeState = Buffer.from(JSON.stringify({
222 code,
223 clientId: 'client-c3',
224 redirectUri,
225 state: 'state-c3',
226 })).toString('base64url');
227
228 let capturedLocation = null;
229 const fakeRes = {
230 statusCode: 200,
231 headers: {},
232 status(code) { this.statusCode = code; return this; },
233 json(data) { this._body = data; },
234 redirect(loc) { capturedLocation = loc; },
235 };
236
237 await completeNativeAuthorization(nativeState, sub, fakeRes);
238
239 assert.ok(capturedLocation, 'must redirect');
240 const url = new URL(capturedLocation);
241 assert.equal(url.searchParams.get('iss'), ISSUER, 'iss must equal discovery issuerUrl');
242 assert.equal(url.searchParams.get('code'), code, 'code must be in redirect');
243 assert.equal(url.searchParams.get('state'), 'state-c3', 'state must be in redirect');
244 });
245
246 // ── C4: durable pending codes survive re-import ──────────────────────────
247
248 it('C4 – pending code survives module reimport (durability across restarts)', async () => {
249 const code = 'c4-durability-code-' + randomUUID();
250 const { savePendingCode } = await import('../hub/gateway/native-as-store.mjs');
251 await savePendingCode(code, {
252 clientId: 'client-c4',
253 codeChallenge: sha256b64url('v4'),
254 redirectUri: 'http://127.0.0.1:9999/cb',
255 state: null,
256 scopes: [],
257 });
258
259 // Re-import the module (simulates a fresh process reading the same file)
260 // Since ES modules are cached, we read the file directly to verify persistence
261 const filePath = path.join(tmpDir, 'native_pending_codes.json');
262 const raw = JSON.parse(await fs.readFile(filePath, 'utf8'));
263 assert.ok(raw.codes && raw.codes[code], 'code must be persisted in the JSON file');
264 assert.equal(raw.codes[code].clientId, 'client-c4');
265 });
266
267 // ── C5: redirect_uri mismatch ────────────────────────────────────────────
268
269 it('C5 – redirect_uri mismatch at token exchange returns invalid_grant', async () => {
270 const sub = 'google:user_c5';
271 const { savePendingCode, bindUserToCode, consumePendingCode } = await import('../hub/gateway/native-as-store.mjs');
272 const verifier = 'verifier-c5';
273 const code = randomUUID();
274 const registeredUri = 'http://127.0.0.1:55000/cb';
275 const wrongUri = 'http://127.0.0.1:55001/cb'; // different port
276
277 await savePendingCode(code, {
278 clientId: 'client-c5',
279 codeChallenge: sha256b64url(verifier),
280 redirectUri: registeredUri,
281 });
282 await bindUserToCode(code, sub);
283
284 const pending = await consumePendingCode(code);
285 assert.ok(pending);
286 // Simulate the C5 check in the token handler
287 const mismatch = wrongUri !== pending.redirectUri;
288 assert.ok(mismatch, 'mismatched redirect_uri must be detected');
289 });
290
291 it('C5 – matching redirect_uri at token exchange passes', async () => {
292 const sub = 'google:user_c5b';
293 const { savePendingCode, bindUserToCode, consumePendingCode } = await import('../hub/gateway/native-as-store.mjs');
294 const verifier = 'verifier-c5b';
295 const code = randomUUID();
296 const correctUri = 'http://127.0.0.1:55002/cb';
297
298 await savePendingCode(code, {
299 clientId: 'client-c5b',
300 codeChallenge: sha256b64url(verifier),
301 redirectUri: correctUri,
302 });
303 await bindUserToCode(code, sub);
304
305 const pending = await consumePendingCode(code);
306 assert.ok(pending);
307 assert.equal(pending.redirectUri, correctUri, 'redirect_uri must match');
308 });
309
310 // ── C2: refresh rotation ─────────────────────────────────────────────────
311
312 it('C2 – refresh rotation returns new token in body (not cookie)', async () => {
313 const sub = 'google:user_c2';
314 const issued = await refreshStore.issue(sub);
315 const rotated = await refreshStore.rotate(issued.token);
316 assert.ok(rotated.ok, 'rotation must succeed');
317 assert.ok(rotated.token, 'new token must be in response');
318 assert.ok(rotated.token !== issued.token, 'new token must differ from old');
319 assert.equal(rotated.sub, sub);
320 });
321
322 it('C2 – reuse detection burns the session', async () => {
323 const sub = 'google:user_c2_reuse';
324 const issued = await refreshStore.issue(sub);
325 const rot1 = await refreshStore.rotate(issued.token);
326 assert.ok(rot1.ok);
327 // Replay the consumed token
328 const rot2 = await refreshStore.rotate(issued.token);
329 assert.ok(!rot2.ok, 'replay must fail');
330 assert.equal(rot2.reason, 'reuse');
331 });
332
333 // ── C6: scope ceiling ────────────────────────────────────────────────────
334
335 it('C6 – member sub cannot be granted admin scope', async () => {
336 const { applyScopeCeiling } = await import('../hub/gateway/native-oauth-provider.mjs');
337 const ceiling = grantedScopes('google:member_user');
338 assert.ok(!ceiling.includes('admin'), 'member ceiling must not include admin');
339 const result = applyScopeCeiling(['vault:read', 'admin'], ceiling);
340 assert.ok(!result.includes('admin'));
341 assert.ok(result.includes('vault:read'));
342 });
343
344 it('C6 – admin sub gets admin ceiling', () => {
345 const ceiling = grantedScopes('google:admin_user');
346 assert.ok(ceiling.includes('admin'));
347 });
348
349 // ── MCP path regression ───────────────────────────────────────────────────
350
351 it('Regression: mcp_access path is unchanged (has type:mcp_access)', async () => {
352 const jwt_lib = (await import('jsonwebtoken')).default;
353 const secret = 'regression-secret';
354 const mcpToken = jwt_lib.sign(
355 { sub: 'google:1', client_id: 'c', scopes: ['vault:read'], type: 'mcp_access' },
356 secret,
357 { expiresIn: 3600 }
358 );
359 const decoded = jwt_lib.verify(mcpToken, secret);
360 assert.equal(decoded.type, 'mcp_access');
361 assert.ok(!decoded.role, 'mcp_access token must not have role claim');
362 });
363 });
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 1 day ago