native-oauth-c1-c6-security.test.mjs
494 lines 19.8 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Security tests for native OAuth C1–C6 changes.
3 * Tier 7 of 7 (centerpiece) — docs/COMPANION-APP-OAUTH-SERVERSIDE-GATE.md §7.
4 *
5 * The gate identifies this tier as the security centerpiece. Tests verify:
6 * - No superset/admin over-grant (C6)
7 * - PKCE still required — plain method rejected
8 * - redirect_uri: non-loopback rejected; mix-up rejected when expectedIssuer set
9 * - Refresh reuse burns the family
10 * - No secret (SESSION_SECRET, JWT, refresh token, code, verifier) in any log/error/redirect
11 * - mcp_access clients not widened by native changes (regression)
12 * - Authorization code cannot be exchanged without completing authorization (userId binding)
13 * - Client mismatch at exchange returns error (not token)
14 * - Expired code returns error
15 * - PKCE plain method rejected at /authorize
16 */
17
18 import assert from 'node:assert/strict';
19 import { describe, it, before, after } from 'node:test';
20 import { createHash, randomUUID } from 'node:crypto';
21 import express from 'express';
22 import fs from 'node:fs/promises';
23 import path from 'node:path';
24 import os from 'node:os';
25 import http from 'node:http';
26
27 function sha256b64url(s) {
28 return createHash('sha256').update(s).digest('base64url');
29 }
30
31 function testClient(app) {
32 const server = http.createServer(app);
33 let baseUrl;
34 return {
35 start() {
36 return new Promise((resolve) => {
37 server.listen(0, '127.0.0.1', () => {
38 baseUrl = `http://127.0.0.1:${server.address().port}`;
39 resolve(baseUrl);
40 });
41 });
42 },
43 stop() {
44 return new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve())));
45 },
46 async fetch(method, urlPath, body, contentType = 'application/x-www-form-urlencoded') {
47 const url = new URL(urlPath, baseUrl);
48 const bodyStr = body
49 ? contentType === 'application/json'
50 ? JSON.stringify(body)
51 : new URLSearchParams(body).toString()
52 : undefined;
53 const res = await fetch(url.toString(), {
54 method,
55 headers: { 'Content-Type': contentType },
56 body: bodyStr,
57 redirect: 'manual',
58 });
59 const text = await res.text();
60 let json = null;
61 try { json = JSON.parse(text); } catch (_) { }
62 return { status: res.status, headers: res.headers, json, text };
63 },
64 };
65 }
66
67 describe('C1–C6 Security: attack surface and invariants', () => {
68 let tmpDir, client, completeNativeAuthorization;
69 let jwt;
70 const SECRET = 'security-test-secret-must-never-leak';
71
72 before(async () => {
73 tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'native-oauth-security-'));
74 process.env.KNOWTATION_GATEWAY_DATA_DIR = tmpDir;
75
76 jwt = (await import('jsonwebtoken')).default;
77
78 function issueAccessToken(sub) {
79 const idx = sub.indexOf(':');
80 const provider = idx > 0 ? sub.slice(0, idx) : '';
81 const id = idx > 0 ? sub.slice(idx + 1) : sub;
82 const role = sub.includes('admin') ? 'admin' : 'member';
83 return jwt.sign({ sub, provider, id, name: '', role }, SECRET, { expiresIn: '24h' });
84 }
85
86 function grantedScopes(sub) {
87 if (sub.includes('admin')) return ['vault:read', 'vault:write', 'admin'];
88 return ['vault:read', 'vault:write'];
89 }
90
91 const { createGatewayRefreshStore } = await import('../hub/gateway/refresh-token-store.mjs');
92 const refreshStore = createGatewayRefreshStore();
93
94 const { createNativeOAuthRouter } = await import('../hub/gateway/native-oauth-provider.mjs');
95 const result = createNativeOAuthRouter({
96 baseUrl: 'http://localhost:0',
97 loginUrl: 'http://localhost:0/auth/login',
98 issueAccessToken,
99 grantedScopes,
100 refreshStore,
101 });
102 completeNativeAuthorization = result.completeNativeAuthorization;
103
104 const app = express();
105 app.use('/api/v1/auth/native', result.router);
106 client = testClient(app);
107 await client.start();
108 });
109
110 after(async () => {
111 await client.stop();
112 delete process.env.KNOWTATION_GATEWAY_DATA_DIR;
113 await fs.rm(tmpDir, { recursive: true, force: true });
114 });
115
116 // ── S-a: Over-privileged companion token ─────────────────────────────────
117
118 it('S-a/C6: member sub NEVER gets admin scope even if requested', async () => {
119 const { savePendingCode, bindUserToCode } = await import('../hub/gateway/native-as-store.mjs');
120 const reg = await client.fetch('POST', '/api/v1/auth/native/register', {
121 redirect_uris: ['http://127.0.0.1:54400/callback'],
122 token_endpoint_auth_method: 'none',
123 }, 'application/json');
124 assert.equal(reg.status, 201);
125 const clientId = reg.json.client_id;
126
127 const code = randomUUID();
128 const verifier = 'sec-admin-test-verifier';
129 await savePendingCode(code, {
130 clientId,
131 codeChallenge: sha256b64url(verifier),
132 redirectUri: 'http://127.0.0.1:54400/callback',
133 scopes: ['admin', 'vault:read', 'vault:write'],
134 });
135 await bindUserToCode(code, 'google:plain_member');
136
137 const tokenRes = await client.fetch('POST', '/api/v1/auth/native/token', {
138 grant_type: 'authorization_code',
139 client_id: clientId,
140 code,
141 code_verifier: verifier,
142 redirect_uri: 'http://127.0.0.1:54400/callback',
143 });
144 assert.equal(tokenRes.status, 200);
145 const scopes = (tokenRes.json.scope || '').split(' ');
146 assert.ok(!scopes.includes('admin'), 'member sub must NEVER receive admin scope');
147 // Verify the JWT itself also does not embed admin
148 const decoded = jwt.decode(tokenRes.json.access_token);
149 assert.equal(decoded.role, 'member');
150 });
151
152 // ── S-b: Refresh token reuse burns the family ────────────────────────────
153
154 it('S-b/C2: replaying a rotated refresh token burns the family', async () => {
155 const { savePendingCode, bindUserToCode } = await import('../hub/gateway/native-as-store.mjs');
156 const reg = await client.fetch('POST', '/api/v1/auth/native/register', {
157 redirect_uris: ['http://127.0.0.1:54401/callback'],
158 token_endpoint_auth_method: 'none',
159 }, 'application/json');
160 const clientId = reg.json.client_id;
161
162 const code = randomUUID();
163 const verifier = 'sec-reuse-verifier';
164 await savePendingCode(code, {
165 clientId,
166 codeChallenge: sha256b64url(verifier),
167 redirectUri: 'http://127.0.0.1:54401/callback',
168 scopes: [],
169 });
170 await bindUserToCode(code, 'google:reuse-victim');
171
172 const t1Res = await client.fetch('POST', '/api/v1/auth/native/token', {
173 grant_type: 'authorization_code',
174 client_id: clientId,
175 code,
176 code_verifier: verifier,
177 redirect_uri: 'http://127.0.0.1:54401/callback',
178 });
179 assert.equal(t1Res.status, 200);
180 const refreshToken1 = t1Res.json.refresh_token;
181
182 // First rotation: valid
183 const rot1 = await client.fetch('POST', '/api/v1/auth/native/token', {
184 grant_type: 'refresh_token',
185 client_id: clientId,
186 refresh_token: refreshToken1,
187 });
188 assert.equal(rot1.status, 200);
189 const refreshToken2 = rot1.json.refresh_token;
190
191 // Replay the already-consumed token: must trigger REFRESH_REUSE
192 const replay = await client.fetch('POST', '/api/v1/auth/native/token', {
193 grant_type: 'refresh_token',
194 client_id: clientId,
195 refresh_token: refreshToken1, // already rotated — reuse
196 });
197 assert.equal(replay.status, 401);
198 assert.ok(
199 replay.json.code === 'REFRESH_REUSE' || replay.json.error === 'invalid_grant',
200 'S-b: reuse must be detected'
201 );
202 });
203
204 // ── S-c: Mix-up defense via iss ──────────────────────────────────────────
205
206 it('S-c/C3: iss in redirect does not contain the authorization code value', async () => {
207 const { savePendingCode } = await import('../hub/gateway/native-as-store.mjs');
208 const code = randomUUID();
209 await savePendingCode(code, {
210 clientId: 'client-sc',
211 codeChallenge: sha256b64url('verifier-sc'),
212 redirectUri: 'http://127.0.0.1:54402/cb',
213 state: 'state-sc',
214 });
215
216 const nativeState = Buffer.from(JSON.stringify({
217 code, clientId: 'client-sc', redirectUri: 'http://127.0.0.1:54402/cb', state: 'state-sc',
218 })).toString('base64url');
219
220 let redirectUrl = null;
221 const fakeRes = {
222 status() { return this; },
223 json() { },
224 redirect(loc) { redirectUrl = loc; },
225 };
226 await completeNativeAuthorization(nativeState, 'google:sc-user', fakeRes);
227 assert.ok(redirectUrl);
228 const url = new URL(redirectUrl);
229 const issValue = url.searchParams.get('iss');
230 // iss must be a URL, not the code
231 assert.ok(issValue && issValue.startsWith('http'), 'iss must be a URL');
232 assert.ok(!issValue.includes(code), 'iss must not contain the code value');
233 // iss must not contain any query string
234 assert.ok(!issValue.includes('?'), 'iss must not have query string (RFC 8414)');
235 });
236
237 // ── S-d: Code cannot be exchanged without completing authorization ────────
238
239 it('S-d: authorization code without userId binding returns invalid_grant', async () => {
240 const { savePendingCode } = await import('../hub/gateway/native-as-store.mjs');
241 const reg = await client.fetch('POST', '/api/v1/auth/native/register', {
242 redirect_uris: ['http://127.0.0.1:54403/callback'],
243 token_endpoint_auth_method: 'none',
244 }, 'application/json');
245 const clientId = reg.json.client_id;
246
247 const code = randomUUID();
248 const verifier = 'no-user-verifier';
249 // Save but do NOT bind a user
250 await savePendingCode(code, {
251 clientId,
252 codeChallenge: sha256b64url(verifier),
253 redirectUri: 'http://127.0.0.1:54403/callback',
254 });
255 // Do NOT call bindUserToCode
256
257 const tokenRes = await client.fetch('POST', '/api/v1/auth/native/token', {
258 grant_type: 'authorization_code',
259 client_id: clientId,
260 code,
261 code_verifier: verifier,
262 redirect_uri: 'http://127.0.0.1:54403/callback',
263 });
264 assert.equal(tokenRes.status, 400);
265 assert.equal(tokenRes.json.error, 'invalid_grant');
266 assert.ok(tokenRes.json.error_description.includes('not completed'));
267 });
268
269 // ── S-e: Open-redirect via non-loopback registered URI rejected ──────────
270
271 it('S-e/C5: non-loopback redirect_uri rejected at registration', async () => {
272 const res = await client.fetch('POST', '/api/v1/auth/native/register', {
273 redirect_uris: ['https://attacker.example.com/steal-code'],
274 token_endpoint_auth_method: 'none',
275 }, 'application/json');
276 assert.equal(res.status, 400, 'S-e: open redirect via non-loopback must be rejected');
277 });
278
279 it('S-e: multiple URIs where one is non-loopback: entire registration rejected', async () => {
280 const res = await client.fetch('POST', '/api/v1/auth/native/register', {
281 redirect_uris: ['http://127.0.0.1:8080/ok', 'https://evil.com/steal'],
282 token_endpoint_auth_method: 'none',
283 }, 'application/json');
284 assert.equal(res.status, 400, 'must reject if any URI is non-loopback');
285 });
286
287 // ── PKCE required: plain method rejected ─────────────────────────────────
288
289 it('PKCE: code_challenge_method=plain is rejected at /authorize', async () => {
290 const reg = await client.fetch('POST', '/api/v1/auth/native/register', {
291 redirect_uris: ['http://127.0.0.1:54404/callback'],
292 token_endpoint_auth_method: 'none',
293 }, 'application/json');
294 const clientId = reg.json.client_id;
295
296 const res = await client.fetch(
297 'GET',
298 `/api/v1/auth/native/authorize?` +
299 `client_id=${encodeURIComponent(clientId)}&` +
300 `redirect_uri=${encodeURIComponent('http://127.0.0.1:54404/callback')}&` +
301 `code_challenge=abc&` +
302 `code_challenge_method=plain` // plain must be rejected
303 );
304 assert.equal(res.status, 400, 'plain PKCE must be rejected (only S256 allowed)');
305 });
306
307 it('PKCE: missing code_challenge_method is rejected', async () => {
308 const reg = await client.fetch('POST', '/api/v1/auth/native/register', {
309 redirect_uris: ['http://127.0.0.1:54405/callback'],
310 token_endpoint_auth_method: 'none',
311 }, 'application/json');
312 const clientId = reg.json.client_id;
313
314 const res = await client.fetch(
315 'GET',
316 `/api/v1/auth/native/authorize?` +
317 `client_id=${encodeURIComponent(clientId)}&` +
318 `redirect_uri=${encodeURIComponent('http://127.0.0.1:54405/callback')}&` +
319 `code_challenge=abc`
320 // missing code_challenge_method
321 );
322 assert.equal(res.status, 400);
323 });
324
325 // ── No secrets in error bodies ───────────────────────────────────────────
326
327 it('No secret leaks: error bodies contain no raw token, code, or verifier values', async () => {
328 // Call token endpoint with bad data and verify the error body doesn't echo secrets
329 const sensitiveCode = 'ultra-secret-code-' + randomUUID();
330 const sensitiveVerifier = 'ultra-secret-verifier-' + randomUUID();
331
332 const res = await client.fetch('POST', '/api/v1/auth/native/token', {
333 grant_type: 'authorization_code',
334 client_id: 'fake-client',
335 code: sensitiveCode,
336 code_verifier: sensitiveVerifier,
337 redirect_uri: 'http://127.0.0.1:1/cb',
338 });
339 const bodyStr = JSON.stringify(res.json);
340 assert.ok(!bodyStr.includes(sensitiveCode), 'error must not echo the code');
341 assert.ok(!bodyStr.includes(sensitiveVerifier), 'error must not echo the verifier');
342 });
343
344 it('No secret leaks: unknown refresh token error does not echo the token', async () => {
345 const reg = await client.fetch('POST', '/api/v1/auth/native/register', {
346 redirect_uris: ['http://127.0.0.1:54406/callback'],
347 token_endpoint_auth_method: 'none',
348 }, 'application/json');
349 const sensitiveToken = 'secret-fake-refresh-token-' + randomUUID();
350
351 const res = await client.fetch('POST', '/api/v1/auth/native/token', {
352 grant_type: 'refresh_token',
353 client_id: reg.json.client_id,
354 refresh_token: sensitiveToken,
355 });
356 assert.equal(res.status, 401);
357 const bodyStr = JSON.stringify(res.json);
358 assert.ok(!bodyStr.includes(sensitiveToken), 'error must not echo the refresh token');
359 });
360
361 // ── Client mismatch at token exchange ────────────────────────────────────
362
363 it('Client mismatch: different client_id at exchange returns error', async () => {
364 const { savePendingCode, bindUserToCode } = await import('../hub/gateway/native-as-store.mjs');
365 const reg1 = await client.fetch('POST', '/api/v1/auth/native/register', {
366 redirect_uris: ['http://127.0.0.1:54407/callback'],
367 token_endpoint_auth_method: 'none',
368 }, 'application/json');
369 const reg2 = await client.fetch('POST', '/api/v1/auth/native/register', {
370 redirect_uris: ['http://127.0.0.1:54407/callback'],
371 token_endpoint_auth_method: 'none',
372 }, 'application/json');
373
374 const code = randomUUID();
375 await savePendingCode(code, {
376 clientId: reg1.json.client_id, // code issued to client 1
377 codeChallenge: sha256b64url('verifier-mismatch'),
378 redirectUri: 'http://127.0.0.1:54407/callback',
379 });
380 await bindUserToCode(code, 'google:user-mismatch');
381
382 const res = await client.fetch('POST', '/api/v1/auth/native/token', {
383 grant_type: 'authorization_code',
384 client_id: reg2.json.client_id, // client 2 tries to use client 1's code
385 code,
386 code_verifier: 'verifier-mismatch',
387 redirect_uri: 'http://127.0.0.1:54407/callback',
388 });
389 assert.equal(res.status, 400);
390 assert.equal(res.json.error, 'invalid_grant');
391 });
392
393 // ── mcp_access regression ────────────────────────────────────────────────
394
395 it('Regression: mcp_access token type and scopes are unaffected by native changes', async () => {
396 // Verify the MCP provider still produces mcp_access tokens with read-only default
397 const { KnowtationOAuthProvider } = await import('../hub/gateway/mcp-oauth-provider.mjs');
398 const provider = new KnowtationOAuthProvider({
399 sessionSecret: SECRET,
400 baseUrl: 'http://localhost:3340',
401 });
402
403 // Simulate exchangeAuthorizationCode with no scopes (default mcp_access behavior)
404 const fakeClient = { client_id: 'mcp-client-1' };
405 const fakeCode = randomUUID();
406 provider._pendingCodes.set(fakeCode, {
407 clientId: 'mcp-client-1',
408 codeChallenge: sha256b64url('mcp-verifier'),
409 redirectUri: 'http://127.0.0.1:9000/callback',
410 state: null,
411 scopes: [],
412 userId: 'google:mcp-user',
413 expires: Date.now() + 300000,
414 });
415
416 const tokens = await provider.exchangeAuthorizationCode(fakeClient, fakeCode, undefined, 'http://127.0.0.1:9000/callback', undefined);
417 const decoded = jwt.decode(tokens.access_token);
418
419 assert.equal(decoded.type, 'mcp_access', 'mcp_access token must still have type claim');
420 assert.ok(Array.isArray(decoded.scopes), 'mcp_access token must have scopes claim');
421 assert.deepEqual(decoded.scopes, ['vault:read'], 'mcp_access default must remain vault:read only');
422 assert.ok(!decoded.role, 'mcp_access token must not have role claim');
423 });
424
425 // ── C3 regression: iss on MCP redirect ───────────────────────────────────
426
427 it('C3 regression: MCP provider completeMcpAuthorization now emits iss', async () => {
428 const { KnowtationOAuthProvider } = await import('../hub/gateway/mcp-oauth-provider.mjs');
429 const provider = new KnowtationOAuthProvider({
430 sessionSecret: SECRET,
431 baseUrl: 'http://localhost:3340',
432 });
433
434 const fakeCode = randomUUID();
435 provider._pendingCodes.set(fakeCode, {
436 clientId: 'mcp-client-c3',
437 codeChallenge: sha256b64url('verifier-c3'),
438 redirectUri: 'http://127.0.0.1:9001/callback',
439 state: 'mcp-state',
440 scopes: [],
441 expires: Date.now() + 300000,
442 });
443
444 const mcpState = Buffer.from(JSON.stringify({
445 code: fakeCode,
446 clientId: 'mcp-client-c3',
447 redirectUri: 'http://127.0.0.1:9001/callback',
448 state: 'mcp-state',
449 })).toString('base64url');
450
451 let redirectUrl = null;
452 const fakeRes = {
453 status() { return this; },
454 json() { },
455 redirect(loc) { redirectUrl = loc; },
456 };
457 provider.completeMcpAuthorization(mcpState, 'google:mcp-user-c3', fakeRes);
458 assert.ok(redirectUrl, 'must redirect');
459 const url = new URL(redirectUrl);
460 assert.ok(url.searchParams.get('iss'), 'C3: iss must be present on MCP redirect');
461 // iss must equal new URL(baseUrl).href (the discovery issuer)
462 assert.equal(url.searchParams.get('iss'), new URL('http://localhost:3340').href);
463 });
464
465 // ── C5 regression: redirect_uri validated in MCP provider ────────────────
466
467 it('C5 regression: MCP provider now validates redirect_uri at exchange', async () => {
468 const { KnowtationOAuthProvider } = await import('../hub/gateway/mcp-oauth-provider.mjs');
469 const provider = new KnowtationOAuthProvider({
470 sessionSecret: SECRET,
471 baseUrl: 'http://localhost:3340',
472 });
473
474 const fakeCode = randomUUID();
475 provider._pendingCodes.set(fakeCode, {
476 clientId: 'mcp-client-c5',
477 codeChallenge: sha256b64url('v-c5'),
478 redirectUri: 'http://127.0.0.1:9002/callback',
479 state: null,
480 scopes: [],
481 userId: 'google:mcp-user-c5',
482 expires: Date.now() + 300000,
483 });
484 const fakeClient = { client_id: 'mcp-client-c5' };
485
486 await assert.rejects(
487 () => provider.exchangeAuthorizationCode(fakeClient, fakeCode, undefined, 'http://127.0.0.1:9999/WRONG', undefined),
488 (err) => {
489 assert.ok(err.message.includes('redirect_uri'), 'C5: must mention redirect_uri in error');
490 return true;
491 }
492 );
493 });
494 });
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