durable-mcp-oauth-unit.test.mjs
136 lines 5.2 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 10 days ago
1 /**
2 * Phase A — durable MCP OAuth: unit tier.
3 * TTL alignment, hash-at-rest via refresh-token-core store, agent meta, provider wiring.
4 */
5
6 import { describe, it, afterEach } from 'node:test';
7 import assert from 'node:assert/strict';
8 import fs from 'node:fs/promises';
9 import path from 'node:path';
10 import jwt from 'jsonwebtoken';
11 import {
12 createDurableMcpProvider,
13 mintMcpTokens,
14 TEST_SECRET,
15 } from './helpers/durable-mcp-oauth-harness.mjs';
16 import {
17 DEFAULT_TOKEN_TTL_MS,
18 DEFAULT_FAMILY_TTL_MS,
19 sanitizeMeta,
20 mergeMeta,
21 } from '../hub/lib/refresh-token-core.mjs';
22 import {
23 MCP_TOKEN_EXPIRY_SECONDS,
24 resolveMcpAgentLabel,
25 KnowtationOAuthProvider,
26 } from '../hub/gateway/mcp-oauth-provider.mjs';
27 import {
28 subFromVerifiedPayload,
29 mcpScopesPermitMethod,
30 shouldMountDurableAgentAuth,
31 } from '../hub/gateway/access-token-authz.mjs';
32
33 const cleanups = [];
34 afterEach(async () => {
35 while (cleanups.length) {
36 const fn = cleanups.pop();
37 await fn();
38 }
39 });
40
41 describe('Phase A unit — MCP durable refresh', () => {
42 it('access TTL is ≤ 1h and refresh TTLs match refresh-token-core defaults', () => {
43 assert.equal(MCP_TOKEN_EXPIRY_SECONDS, 3600);
44 assert.equal(DEFAULT_TOKEN_TTL_MS, 30 * 24 * 60 * 60 * 1000);
45 assert.equal(DEFAULT_FAMILY_TTL_MS, 90 * 24 * 60 * 60 * 1000);
46 });
47
48 it('sanitizeMeta keeps agent / client_id / scopes and drops unknown fields', () => {
49 const meta = sanitizeMeta({
50 agent: 'Hermes-bf',
51 client_id: 'abc',
52 scopes: 'vault:read vault:write',
53 evil: { nested: true },
54 ua: 'x'.repeat(400),
55 });
56 assert.equal(meta.agent, 'Hermes-bf');
57 assert.equal(meta.client_id, 'abc');
58 assert.equal(meta.scopes, 'vault:read vault:write');
59 assert.equal(meta.evil, undefined);
60 assert.equal(meta.ua.length, 256);
61 });
62
63 it('mergeMeta preserves agent across rotate when incoming meta is empty', () => {
64 const merged = mergeMeta({ agent: 'A', client_id: 'c1', scopes: 'vault:read' }, {});
65 assert.equal(merged.agent, 'A');
66 assert.equal(merged.client_id, 'c1');
67 });
68
69 it('resolveMcpAgentLabel prefers client_name then client_id', () => {
70 assert.equal(resolveMcpAgentLabel({ client_name: 'Hermes', client_id: 'id1' }), 'Hermes');
71 assert.equal(resolveMcpAgentLabel({ client_id: 'id1' }), 'id1');
72 assert.equal(resolveMcpAgentLabel({ client_id: 'id1' }, 'override'), 'override');
73 });
74
75 it('KnowtationOAuthProvider requires refreshStore', () => {
76 assert.throws(
77 () => new KnowtationOAuthProvider({ sessionSecret: TEST_SECRET, baseUrl: 'http://localhost:3340' }),
78 /refreshStore/
79 );
80 });
81
82 it('issued refresh is hash-at-rest (raw secret absent from store file)', async () => {
83 const { provider, cleanup, dir } = await createDurableMcpProvider();
84 cleanups.push(cleanup);
85 const { tokens } = await mintMcpTokens(provider, { clientName: 'Cursor IDE' });
86 const secret = tokens.refresh_token.split('.')[1];
87 const raw = await fs.readFile(path.join(dir, 'hosted_refresh_tokens.json'), 'utf8');
88 assert.ok(!raw.includes(secret), 'raw refresh secret must not appear in store file');
89 assert.ok(!raw.includes(tokens.access_token), 'access JWT must not be persisted in refresh store');
90 const payload = jwt.verify(tokens.access_token, TEST_SECRET);
91 assert.equal(payload.type, 'mcp_access');
92 assert.equal(payload.scopes[0], 'vault:read');
93 });
94
95 it('refresh record meta includes agent label from client_name', async () => {
96 const { provider, store, cleanup } = await createDurableMcpProvider();
97 cleanups.push(cleanup);
98 const { tokens } = await mintMcpTokens(provider, { clientName: 'Hermes-cofounder-1' });
99 const peeked = await store.peek(tokens.refresh_token);
100 assert.ok(peeked);
101 assert.equal(peeked.meta.agent, 'Hermes-cofounder-1');
102 assert.ok(peeked.meta.client_id);
103 assert.equal(peeked.meta.scopes, 'vault:read');
104 });
105 });
106
107 describe('Phase A unit — MCP scope REST guard helpers', () => {
108 it('vault:read permits GET but not POST', () => {
109 assert.equal(mcpScopesPermitMethod(['vault:read'], 'GET'), true);
110 assert.equal(mcpScopesPermitMethod(['vault:read'], 'POST'), false);
111 assert.equal(mcpScopesPermitMethod(['vault:write'], 'POST'), true);
112 });
113
114 it('subFromVerifiedPayload blocks mcp_access vault:read on mutating methods', () => {
115 const payload = { sub: 'google:1', type: 'mcp_access', scopes: ['vault:read'] };
116 assert.equal(subFromVerifiedPayload(payload, { method: 'GET' }), 'google:1');
117 assert.equal(subFromVerifiedPayload(payload, { method: 'POST' }), null);
118 });
119
120 it('web session JWT (no mcp_access type) is not scope-blocked here', () => {
121 const payload = { sub: 'google:1', role: 'member' };
122 assert.equal(subFromVerifiedPayload(payload, { method: 'POST' }), 'google:1');
123 });
124
125 it('shouldMountDurableAgentAuth is false under offline-lock or Netlify', () => {
126 assert.equal(shouldMountDurableAgentAuth({
127 sessionSecret: 'x', netlify: false, offlineLockedActive: false,
128 }), true);
129 assert.equal(shouldMountDurableAgentAuth({
130 sessionSecret: 'x', netlify: false, offlineLockedActive: true,
131 }), false);
132 assert.equal(shouldMountDurableAgentAuth({
133 sessionSecret: 'x', netlify: true, offlineLockedActive: false,
134 }), false);
135 });
136 });
File History 1 commit
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 10 days ago