durable-mcp-oauth-security.test.mjs
91 lines 3.4 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 11 days ago
1 /**
2 * Phase A — durable MCP OAuth: security tier.
3 * reuse → family revoke; revoked rejected; no secrets in logs; mcp_access scope on REST.
4 */
5
6 import { describe, it, afterEach } from 'node:test';
7 import assert from 'node:assert/strict';
8 import jwt from 'jsonwebtoken';
9 import {
10 createDurableMcpProvider,
11 mintMcpTokens,
12 TEST_SECRET,
13 } from './helpers/durable-mcp-oauth-harness.mjs';
14 import { subFromVerifiedPayload } from '../hub/gateway/access-token-authz.mjs';
15
16 const cleanups = [];
17 afterEach(async () => {
18 while (cleanups.length) await cleanups.pop()();
19 });
20
21 describe('Phase A security — refresh reuse / revoke / logging', () => {
22 it('replaying a rotated refresh revokes the family and rejects further use', async () => {
23 const { provider, cleanup } = await createDurableMcpProvider();
24 cleanups.push(cleanup);
25 const { client, tokens } = await mintMcpTokens(provider, { scopes: ['vault:read'] });
26 const firstRefresh = tokens.refresh_token;
27 const rotated = await provider.exchangeRefreshToken(client, firstRefresh);
28 assert.ok(rotated.refresh_token);
29
30 await assert.rejects(
31 () => provider.exchangeRefreshToken(client, firstRefresh),
32 /reuse|revoked|Unknown/i
33 );
34 await assert.rejects(
35 () => provider.exchangeRefreshToken(client, rotated.refresh_token),
36 /revoked|Unknown|reuse/i
37 );
38 });
39
40 it('explicit revoke rejects subsequent refresh', async () => {
41 const { provider, cleanup } = await createDurableMcpProvider();
42 cleanups.push(cleanup);
43 const { client, tokens } = await mintMcpTokens(provider);
44 await provider.revokeToken(client, { token: tokens.refresh_token });
45 await assert.rejects(
46 () => provider.exchangeRefreshToken(client, tokens.refresh_token),
47 /revoked|Unknown/
48 );
49 });
50
51 it('error paths never include raw refresh secrets', async () => {
52 const { provider, cleanup } = await createDurableMcpProvider();
53 cleanups.push(cleanup);
54 const { client, tokens } = await mintMcpTokens(provider);
55 const secret = tokens.refresh_token;
56 try {
57 await provider.exchangeRefreshToken(client, 'not-a-token');
58 assert.fail('expected throw');
59 } catch (e) {
60 assert.ok(!String(e.message).includes(secret));
61 assert.ok(!String(e.stack || '').includes(secret.split('.')[1] || '___'));
62 }
63 });
64
65 it('confused deputy: mcp_access vault:read MUST NOT authorize REST writes', () => {
66 const access = jwt.sign(
67 { sub: 'google:deputy', client_id: 'c', scopes: ['vault:read'], type: 'mcp_access' },
68 TEST_SECRET,
69 { expiresIn: 3600 }
70 );
71 const payload = jwt.verify(access, TEST_SECRET);
72 assert.equal(subFromVerifiedPayload(payload, { method: 'GET' }), 'google:deputy');
73 assert.equal(
74 subFromVerifiedPayload(payload, { method: 'POST' }),
75 null,
76 'vault:read mcp_access must not write over REST'
77 );
78 assert.equal(subFromVerifiedPayload(payload, { method: 'PUT' }), null);
79 assert.equal(subFromVerifiedPayload(payload, { method: 'DELETE' }), null);
80 assert.equal(subFromVerifiedPayload(payload, { method: 'PATCH' }), null);
81 });
82
83 it('mcp_access with vault:write may mutate REST (identity only; role still separate)', () => {
84 const payload = {
85 sub: 'google:writer',
86 type: 'mcp_access',
87 scopes: ['vault:read', 'vault:write'],
88 };
89 assert.equal(subFromVerifiedPayload(payload, { method: 'POST' }), 'google:writer');
90 });
91 });
File History 1 commit
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 11 days ago