durable-mcp-oauth-security.test.mjs
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