|
| 1 | +import { describe, expect, it } from 'vitest' |
| 2 | +import { |
| 3 | + mintDelegatedToolToken, |
| 4 | + verifyDelegatedToolToken, |
| 5 | +} from '../token.js' |
| 6 | +import { issueDelegatedToolLease } from '../lease.js' |
| 7 | +import { |
| 8 | + handleDelegatedToolCall, |
| 9 | + handleDelegatedToolRequest, |
| 10 | + DelegatedToolInvocationError, |
| 11 | + type DelegatedToolCallSeams, |
| 12 | + type ResolvedDelegatedTool, |
| 13 | +} from '../handler.js' |
| 14 | + |
| 15 | +const SECRET = 'test-delegated-secret' |
| 16 | +const WORKSPACE = 'ws_123' |
| 17 | + |
| 18 | +describe('mint/verify token', () => { |
| 19 | + it('round-trips claims', async () => { |
| 20 | + const token = await mintDelegatedToolToken({ |
| 21 | + workspaceId: WORKSPACE, |
| 22 | + allowedTools: ['gcal__events.create', 'slack__post'], |
| 23 | + ttlSeconds: 600, |
| 24 | + secret: SECRET, |
| 25 | + }) |
| 26 | + expect(token).toBeDefined() |
| 27 | + expect(token!.startsWith('dtt_')).toBe(true) |
| 28 | + |
| 29 | + const claims = await verifyDelegatedToolToken(token!, { secret: SECRET }) |
| 30 | + expect(claims).not.toBeNull() |
| 31 | + expect(claims!.workspaceId).toBe(WORKSPACE) |
| 32 | + expect(claims!.allowedTools).toEqual(['gcal__events.create', 'slack__post']) |
| 33 | + expect(claims!.expiresAt).toBeGreaterThan(Date.now()) |
| 34 | + }) |
| 35 | + |
| 36 | + it('honors a custom prefix', async () => { |
| 37 | + const token = await mintDelegatedToolToken({ |
| 38 | + workspaceId: WORKSPACE, |
| 39 | + allowedTools: ['a'], |
| 40 | + ttlSeconds: 60, |
| 41 | + secret: SECRET, |
| 42 | + prefix: 'gtmmcp_', |
| 43 | + }) |
| 44 | + expect(token!.startsWith('gtmmcp_')).toBe(true) |
| 45 | + // default-prefix verify rejects a custom-prefix token |
| 46 | + expect(await verifyDelegatedToolToken(token!, { secret: SECRET })).toBeNull() |
| 47 | + expect(await verifyDelegatedToolToken(token!, { secret: SECRET, prefix: 'gtmmcp_' })).not.toBeNull() |
| 48 | + }) |
| 49 | + |
| 50 | + it('fail-closed: no secret ⇒ no token', async () => { |
| 51 | + expect(await mintDelegatedToolToken({ workspaceId: WORKSPACE, allowedTools: [], ttlSeconds: 60 })).toBeUndefined() |
| 52 | + expect(await mintDelegatedToolToken({ workspaceId: WORKSPACE, allowedTools: [], ttlSeconds: 60, secret: ' ' })).toBeUndefined() |
| 53 | + }) |
| 54 | + |
| 55 | + it('rejects an expired token', async () => { |
| 56 | + const t0 = 1_000_000_000_000 |
| 57 | + const token = await mintDelegatedToolToken({ |
| 58 | + workspaceId: WORKSPACE, |
| 59 | + allowedTools: ['x'], |
| 60 | + ttlSeconds: 60, |
| 61 | + secret: SECRET, |
| 62 | + now: t0, |
| 63 | + }) |
| 64 | + expect(await verifyDelegatedToolToken(token!, { secret: SECRET, now: t0 + 59_000 })).not.toBeNull() |
| 65 | + expect(await verifyDelegatedToolToken(token!, { secret: SECRET, now: t0 + 60_001 })).toBeNull() |
| 66 | + }) |
| 67 | + |
| 68 | + it('rejects a forged signature', async () => { |
| 69 | + const token = await mintDelegatedToolToken({ |
| 70 | + workspaceId: WORKSPACE, |
| 71 | + allowedTools: ['x'], |
| 72 | + ttlSeconds: 60, |
| 73 | + secret: SECRET, |
| 74 | + }) |
| 75 | + // tamper with the signature segment |
| 76 | + const tampered = token!.slice(0, -2) + (token!.endsWith('AA') ? 'BB' : 'AA') |
| 77 | + expect(await verifyDelegatedToolToken(tampered, { secret: SECRET })).toBeNull() |
| 78 | + // wrong secret rejects |
| 79 | + expect(await verifyDelegatedToolToken(token!, { secret: 'other-secret' })).toBeNull() |
| 80 | + }) |
| 81 | + |
| 82 | + it('rejects a tampered claims payload (privilege escalation attempt)', async () => { |
| 83 | + const token = await mintDelegatedToolToken({ |
| 84 | + workspaceId: WORKSPACE, |
| 85 | + allowedTools: ['read'], |
| 86 | + ttlSeconds: 60, |
| 87 | + secret: SECRET, |
| 88 | + }) |
| 89 | + const [, sig] = token!.slice('dtt_'.length).split('.') |
| 90 | + const forgedClaims = Buffer.from( |
| 91 | + JSON.stringify({ workspaceId: WORKSPACE, allowedTools: ['admin.delete'], expiresAt: Date.now() + 60_000 }), |
| 92 | + ) |
| 93 | + .toString('base64url') |
| 94 | + const forged = `dtt_${forgedClaims}.${sig}` |
| 95 | + expect(await verifyDelegatedToolToken(forged, { secret: SECRET })).toBeNull() |
| 96 | + }) |
| 97 | + |
| 98 | + it('rejects malformed tokens and wrong prefix', async () => { |
| 99 | + expect(await verifyDelegatedToolToken('not-a-token', { secret: SECRET })).toBeNull() |
| 100 | + expect(await verifyDelegatedToolToken('dtt_only-one-segment', { secret: SECRET })).toBeNull() |
| 101 | + expect(await verifyDelegatedToolToken('dtt_.sig', { secret: SECRET })).toBeNull() |
| 102 | + expect(await verifyDelegatedToolToken('foo_abc.def', { secret: SECRET })).toBeNull() |
| 103 | + }) |
| 104 | +}) |
| 105 | + |
| 106 | +describe('issueDelegatedToolLease', () => { |
| 107 | + it('packages token + allow-list + expiry + callbackUrl', async () => { |
| 108 | + const t0 = 2_000_000_000_000 |
| 109 | + const lease = await issueDelegatedToolLease({ |
| 110 | + workspaceId: WORKSPACE, |
| 111 | + allowedTools: ['gcal__events.create'], |
| 112 | + ttlSeconds: 300, |
| 113 | + secret: SECRET, |
| 114 | + callbackUrl: 'https://app.example/api/delegated/mcp', |
| 115 | + now: t0, |
| 116 | + }) |
| 117 | + expect(lease).not.toBeNull() |
| 118 | + expect(lease!.allowedTools).toEqual(['gcal__events.create']) |
| 119 | + expect(lease!.expiresAt).toBe(t0 + 300_000) |
| 120 | + expect(lease!.callbackUrl).toBe('https://app.example/api/delegated/mcp') |
| 121 | + const claims = await verifyDelegatedToolToken(lease!.token, { secret: SECRET, now: t0 }) |
| 122 | + expect(claims!.workspaceId).toBe(WORKSPACE) |
| 123 | + }) |
| 124 | + |
| 125 | + it('fail-closed: no secret ⇒ null lease', async () => { |
| 126 | + expect( |
| 127 | + await issueDelegatedToolLease({ workspaceId: WORKSPACE, allowedTools: ['x'], ttlSeconds: 60 }), |
| 128 | + ).toBeNull() |
| 129 | + }) |
| 130 | +}) |
| 131 | + |
| 132 | +function tool(name: string, invoke?: ResolvedDelegatedTool['invoke']): ResolvedDelegatedTool { |
| 133 | + return { |
| 134 | + name, |
| 135 | + description: `desc ${name}`, |
| 136 | + invoke: invoke ?? (async (args) => ({ ok: true, echo: args })), |
| 137 | + } |
| 138 | +} |
| 139 | + |
| 140 | +function seams(overrides: Partial<DelegatedToolCallSeams> = {}): DelegatedToolCallSeams { |
| 141 | + return { |
| 142 | + verifyToken: async (bearer) => verifyDelegatedToolToken(bearer, { secret: SECRET }), |
| 143 | + resolveTool: async (_ws, name) => (name === 'gcal__create' ? tool(name) : null), |
| 144 | + isIntegrationConnected: async () => true, |
| 145 | + ...overrides, |
| 146 | + } |
| 147 | +} |
| 148 | + |
| 149 | +async function bearer(allowedTools: string[]): Promise<string> { |
| 150 | + const token = await mintDelegatedToolToken({ workspaceId: WORKSPACE, allowedTools, ttlSeconds: 600, secret: SECRET }) |
| 151 | + return `Bearer ${token}` |
| 152 | +} |
| 153 | + |
| 154 | +describe('handleDelegatedToolCall — JSON-RPC envelope', () => { |
| 155 | + it('initialize returns protocol + serverInfo', async () => { |
| 156 | + const res = await handleDelegatedToolCall( |
| 157 | + { jsonrpc: '2.0', id: 1, method: 'initialize' }, |
| 158 | + await bearer(['gcal__create']), |
| 159 | + seams({ serverInfo: { name: 'creative', version: '9' } }), |
| 160 | + ) |
| 161 | + expect(res.id).toBe(1) |
| 162 | + expect((res.result as any).serverInfo).toEqual({ name: 'creative', version: '9' }) |
| 163 | + expect((res.result as any).protocolVersion).toBeDefined() |
| 164 | + }) |
| 165 | + |
| 166 | + it('tools/list returns allow-list ∩ resolvable ∩ connected', async () => { |
| 167 | + const res = await handleDelegatedToolCall( |
| 168 | + { id: 2, method: 'tools/list' }, |
| 169 | + await bearer(['gcal__create', 'unknown__tool']), |
| 170 | + seams(), |
| 171 | + ) |
| 172 | + const tools = (res.result as any).tools |
| 173 | + expect(tools).toHaveLength(1) |
| 174 | + expect(tools[0].name).toBe('gcal__create') |
| 175 | + expect(tools[0].inputSchema).toEqual({ type: 'object' }) |
| 176 | + }) |
| 177 | + |
| 178 | + it('tools/list omits disconnected tools', async () => { |
| 179 | + const res = await handleDelegatedToolCall( |
| 180 | + { id: 3, method: 'tools/list' }, |
| 181 | + await bearer(['gcal__create']), |
| 182 | + seams({ isIntegrationConnected: async () => false }), |
| 183 | + ) |
| 184 | + expect((res.result as any).tools).toHaveLength(0) |
| 185 | + }) |
| 186 | + |
| 187 | + it('tools/call happy path invokes and returns result', async () => { |
| 188 | + const res = await handleDelegatedToolCall( |
| 189 | + { id: 4, method: 'tools/call', params: { name: 'gcal__create', arguments: { title: 'Sync' } } }, |
| 190 | + await bearer(['gcal__create']), |
| 191 | + seams(), |
| 192 | + ) |
| 193 | + expect(res.error).toBeUndefined() |
| 194 | + expect(res.result).toEqual({ ok: true, echo: { title: 'Sync' } }) |
| 195 | + }) |
| 196 | + |
| 197 | + it('unknown method ⇒ -32601', async () => { |
| 198 | + const res = await handleDelegatedToolCall( |
| 199 | + { id: 5, method: 'frobnicate' }, |
| 200 | + await bearer(['gcal__create']), |
| 201 | + seams(), |
| 202 | + ) |
| 203 | + expect(res.error?.code).toBe(-32601) |
| 204 | + }) |
| 205 | +}) |
| 206 | + |
| 207 | +describe('handleDelegatedToolCall — fail-closed gates', () => { |
| 208 | + it('gate 1: missing/invalid bearer ⇒ -32001', async () => { |
| 209 | + const res = await handleDelegatedToolCall({ id: 1, method: 'tools/list' }, undefined, seams()) |
| 210 | + expect(res.error?.code).toBe(-32001) |
| 211 | + const res2 = await handleDelegatedToolCall({ id: 1, method: 'tools/list' }, 'Bearer dtt_bogus.sig', seams()) |
| 212 | + expect(res2.error?.code).toBe(-32001) |
| 213 | + }) |
| 214 | + |
| 215 | + it('gate 2: tool not in lease allow-list ⇒ -32602', async () => { |
| 216 | + const res = await handleDelegatedToolCall( |
| 217 | + { id: 2, method: 'tools/call', params: { name: 'gcal__create' } }, |
| 218 | + await bearer(['slack__post']), |
| 219 | + seams(), |
| 220 | + ) |
| 221 | + expect(res.error?.code).toBe(-32602) |
| 222 | + expect(res.error?.message).toContain('not delegated') |
| 223 | + }) |
| 224 | + |
| 225 | + it('gate 3: tool does not resolve for workspace ⇒ -32602', async () => { |
| 226 | + const res = await handleDelegatedToolCall( |
| 227 | + { id: 3, method: 'tools/call', params: { name: 'ghost__tool' } }, |
| 228 | + await bearer(['ghost__tool']), |
| 229 | + seams(), |
| 230 | + ) |
| 231 | + expect(res.error?.code).toBe(-32602) |
| 232 | + expect(res.error?.message).toContain('not available') |
| 233 | + }) |
| 234 | + |
| 235 | + it('gate 4: integration disconnected ⇒ -32602, invoke never runs', async () => { |
| 236 | + let invoked = false |
| 237 | + const res = await handleDelegatedToolCall( |
| 238 | + { id: 4, method: 'tools/call', params: { name: 'gcal__create' } }, |
| 239 | + await bearer(['gcal__create']), |
| 240 | + seams({ |
| 241 | + resolveTool: async (_ws, name) => tool(name, async () => { invoked = true; return {} }), |
| 242 | + isIntegrationConnected: async () => false, |
| 243 | + }), |
| 244 | + ) |
| 245 | + expect(res.error?.code).toBe(-32602) |
| 246 | + expect(res.error?.message).toContain('not connected') |
| 247 | + expect(invoked).toBe(false) |
| 248 | + }) |
| 249 | + |
| 250 | + it('expired bearer is rejected at the verify gate', async () => { |
| 251 | + const t0 = 1_000_000_000_000 |
| 252 | + const token = await mintDelegatedToolToken({ workspaceId: WORKSPACE, allowedTools: ['gcal__create'], ttlSeconds: 60, secret: SECRET, now: t0 }) |
| 253 | + const res = await handleDelegatedToolCall( |
| 254 | + { id: 5, method: 'tools/call', params: { name: 'gcal__create' } }, |
| 255 | + `Bearer ${token}`, |
| 256 | + seams({ verifyToken: async (b) => verifyDelegatedToolToken(b, { secret: SECRET, now: t0 + 120_000 }) }), |
| 257 | + ) |
| 258 | + expect(res.error?.code).toBe(-32001) |
| 259 | + }) |
| 260 | +}) |
| 261 | + |
| 262 | +describe('handleDelegatedToolCall — invocation errors', () => { |
| 263 | + it('DelegatedToolInvocationError surfaces its code + data', async () => { |
| 264 | + const res = await handleDelegatedToolCall( |
| 265 | + { id: 1, method: 'tools/call', params: { name: 'gcal__create' } }, |
| 266 | + await bearer(['gcal__create']), |
| 267 | + seams({ |
| 268 | + resolveTool: async (_ws, name) => |
| 269 | + tool(name, async () => { |
| 270 | + throw new DelegatedToolInvocationError('hub 502', { code: -32050, data: { status: 502 } }) |
| 271 | + }), |
| 272 | + }), |
| 273 | + ) |
| 274 | + expect(res.error?.code).toBe(-32050) |
| 275 | + expect(res.error?.data).toEqual({ status: 502 }) |
| 276 | + }) |
| 277 | + |
| 278 | + it('arbitrary throw ⇒ -32000', async () => { |
| 279 | + const res = await handleDelegatedToolCall( |
| 280 | + { id: 2, method: 'tools/call', params: { name: 'gcal__create' } }, |
| 281 | + await bearer(['gcal__create']), |
| 282 | + seams({ resolveTool: async (_ws, name) => tool(name, async () => { throw new Error('boom') }) }), |
| 283 | + ) |
| 284 | + expect(res.error?.code).toBe(-32000) |
| 285 | + expect(res.error?.message).toBe('boom') |
| 286 | + }) |
| 287 | +}) |
| 288 | + |
| 289 | +describe('handleDelegatedToolRequest — HTTP transport', () => { |
| 290 | + function req(bodyObj: unknown, headers: Record<string, string> = {}, method = 'POST'): Request { |
| 291 | + return new Request('https://app.example/api/delegated/mcp', { |
| 292 | + method, |
| 293 | + headers: { 'content-type': 'application/json', ...headers }, |
| 294 | + body: method === 'POST' ? JSON.stringify(bodyObj) : undefined, |
| 295 | + }) |
| 296 | + } |
| 297 | + |
| 298 | + it('non-POST ⇒ 405', async () => { |
| 299 | + const res = await handleDelegatedToolRequest(req({}, {}, 'GET'), seams()) |
| 300 | + expect(res.status).toBe(405) |
| 301 | + }) |
| 302 | + |
| 303 | + it('bad JSON ⇒ parse error -32700', async () => { |
| 304 | + const request = new Request('https://app.example/x', { method: 'POST', body: '{not json' }) |
| 305 | + const res = await handleDelegatedToolRequest(request, seams()) |
| 306 | + const json = (await res.json()) as any |
| 307 | + expect(json.error.code).toBe(-32700) |
| 308 | + }) |
| 309 | + |
| 310 | + it('unauthorized ⇒ 401 status', async () => { |
| 311 | + const res = await handleDelegatedToolRequest(req({ id: 1, method: 'tools/list' }), seams()) |
| 312 | + expect(res.status).toBe(401) |
| 313 | + }) |
| 314 | + |
| 315 | + it('authorized call ⇒ 200 with result', async () => { |
| 316 | + const res = await handleDelegatedToolRequest( |
| 317 | + req( |
| 318 | + { id: 1, method: 'tools/call', params: { name: 'gcal__create', arguments: { a: 1 } } }, |
| 319 | + { authorization: await bearer(['gcal__create']) }, |
| 320 | + ), |
| 321 | + seams(), |
| 322 | + ) |
| 323 | + expect(res.status).toBe(200) |
| 324 | + const json = (await res.json()) as any |
| 325 | + expect(json.result).toEqual({ ok: true, echo: { a: 1 } }) |
| 326 | + }) |
| 327 | + |
| 328 | + it('authenticated JSON-RPC error rides a 200', async () => { |
| 329 | + const res = await handleDelegatedToolRequest( |
| 330 | + req({ id: 1, method: 'tools/call', params: { name: 'nope' } }, { authorization: await bearer(['gcal__create']) }), |
| 331 | + seams(), |
| 332 | + ) |
| 333 | + expect(res.status).toBe(200) |
| 334 | + const json = (await res.json()) as any |
| 335 | + expect(json.error.code).toBe(-32602) |
| 336 | + }) |
| 337 | +}) |
0 commit comments