mcp-oauth.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. // MCP 支持完善 · 阶段 D:OAuth 授权(按服务共享,docs/design/16-mcp-support.md §5)
  2. import crypto from 'node:crypto'
  3. import fs from 'node:fs'
  4. import http from 'node:http'
  5. import type { AddressInfo } from 'node:net'
  6. import path from 'node:path'
  7. import pino from 'pino'
  8. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  9. import { Client } from '@modelcontextprotocol/sdk/client/index.js'
  10. import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
  11. import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
  12. import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
  13. import { createApp, type App } from '../src/app'
  14. import { config } from '../src/config'
  15. import { buildServer } from '../src/http/server'
  16. import type { GatewayRunContext } from '../src/mcp-gateway/tokens'
  17. import { resolveAccess } from '../src/services/access-policy'
  18. let app: App
  19. let server: Awaited<ReturnType<typeof buildServer>>
  20. let base: string
  21. let upstream: http.Server
  22. let origin: string
  23. /** 测试用的授权服务器 + 受保护的 MCP 服务(同一个端口) */
  24. const valid = new Set<string>()
  25. const codes = new Map<string, { challenge: string; redirectUri: string }>()
  26. const refreshTokens = new Set<string>()
  27. const grants: string[] = []
  28. function startUpstream(): Promise<void> {
  29. upstream = http.createServer(async (req, res) => {
  30. const url = new URL(req.url!, origin)
  31. let body = ''
  32. req.on('data', c => (body += c))
  33. await new Promise(r => req.on('end', r))
  34. const json = (status: number, v: unknown) => res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(v))
  35. if (url.pathname.startsWith('/.well-known/oauth-protected-resource')) return json(200, { resource: `${origin}/mcp`, authorization_servers: [origin] })
  36. if (url.pathname === '/.well-known/oauth-authorization-server') {
  37. return json(200, {
  38. issuer: origin, authorization_endpoint: `${origin}/authorize`, token_endpoint: `${origin}/token`, registration_endpoint: `${origin}/register`,
  39. response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], code_challenge_methods_supported: ['S256'],
  40. token_endpoint_auth_methods_supported: ['none', 'client_secret_post'],
  41. })
  42. }
  43. if (url.pathname === '/register') return json(201, { ...JSON.parse(body), client_id: 'dyn-client', client_id_issued_at: Math.floor(Date.now() / 1000) })
  44. if (url.pathname === '/authorize') {
  45. // 模拟用户登录并同意:直接带授权码跳回
  46. const code = crypto.randomBytes(8).toString('hex')
  47. codes.set(code, { challenge: url.searchParams.get('code_challenge')!, redirectUri: url.searchParams.get('redirect_uri')! })
  48. const back = new URL(url.searchParams.get('redirect_uri')!)
  49. back.searchParams.set('code', code)
  50. back.searchParams.set('state', url.searchParams.get('state')!)
  51. return res.writeHead(302, { location: back.toString() }).end()
  52. }
  53. if (url.pathname === '/token') {
  54. const p = new URLSearchParams(body)
  55. grants.push(p.get('grant_type')!)
  56. if (p.get('grant_type') === 'authorization_code') {
  57. const c = codes.get(p.get('code')!)
  58. const challenge = crypto.createHash('sha256').update(p.get('code_verifier') ?? '').digest('base64url')
  59. if (!c || c.challenge !== challenge || c.redirectUri !== p.get('redirect_uri')) return json(400, { error: 'invalid_grant' })
  60. codes.delete(p.get('code')!)
  61. } else if (p.get('grant_type') === 'refresh_token') {
  62. if (!refreshTokens.has(p.get('refresh_token')!)) return json(400, { error: 'invalid_grant' })
  63. } else return json(400, { error: 'unsupported_grant_type' })
  64. const access = crypto.randomBytes(8).toString('hex')
  65. const refresh = crypto.randomBytes(8).toString('hex')
  66. valid.add(access)
  67. refreshTokens.add(refresh)
  68. return json(200, { access_token: access, token_type: 'Bearer', expires_in: 3600, refresh_token: refresh })
  69. }
  70. if (url.pathname === '/mcp') {
  71. const token = req.headers.authorization?.replace(/^Bearer /, '')
  72. if (!token || !valid.has(token)) {
  73. return res.writeHead(401, { 'www-authenticate': `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"` }).end()
  74. }
  75. const mcp = new McpServer({ name: 'secure', version: '1.0.0' })
  76. mcp.registerTool('whoami', { description: '当前账号', inputSchema: {}, annotations: { readOnlyHint: true } }, async () => ({ content: [{ type: 'text', text: 'account:alice' }] }))
  77. const t = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
  78. res.on('close', () => { void t.close(); void mcp.close() })
  79. await mcp.connect(t)
  80. return t.handleRequest(req, res, body ? JSON.parse(body) : undefined)
  81. }
  82. res.writeHead(404).end()
  83. })
  84. return new Promise(r => upstream.listen(0, '127.0.0.1', () => {
  85. origin = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`
  86. r()
  87. }))
  88. }
  89. function runCtx(serverId: string): GatewayRunContext {
  90. const ws = path.join(config.dataDir, 'workspaces', `ses_oauth_${Math.random().toString(36).slice(2)}`)
  91. fs.mkdirSync(ws, { recursive: true })
  92. const settings = app.settings.get()
  93. return {
  94. sessionId: `ses_${Math.random().toString(36).slice(2)}`, runId: 'r', provider: 'claude', workspaceDir: ws, permissionMode: 'auto', settings,
  95. access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
  96. knowledgeRoots: [], mcpServerIds: [serverId], audit: () => {}, interact: async () => ({ resolution: 'allowed', scope: 'once' }),
  97. }
  98. }
  99. async function callWhoami(serverId: string) {
  100. const client = new Client({ name: 'test', version: '1' })
  101. await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`), { requestInit: { headers: { Authorization: `Bearer ${app.gatewayTokens.issue(runCtx(serverId))}` } } }))
  102. try {
  103. return await client.callTool({ name: 'secure__whoami', arguments: {} }) as { content: { text: string }[]; isError?: boolean }
  104. } finally {
  105. await client.close()
  106. }
  107. }
  108. beforeAll(async () => {
  109. const log = pino({ level: 'silent' })
  110. app = createApp(log, { providers: {} })
  111. server = await buildServer(app, { logger: log })
  112. base = await server.listen({ port: 0, host: '127.0.0.1' })
  113. await startUpstream()
  114. })
  115. afterAll(async () => {
  116. await app.mcpUpstream.closeAll()
  117. await server.close()
  118. upstream.closeAllConnections()
  119. upstream.close()
  120. })
  121. describe('OAuth 授权', () => {
  122. it('未授权时连接给出明确提示;发起授权(动态注册 + PKCE)→ 回调换取令牌 → 工具可用;令牌失效后自动续期;可以清除', async () => {
  123. const s = app.mcpServers.create({
  124. name: 'secure', title: '受保护服务', description: '', transport: 'http', enabled: true, url: `${origin}/mcp`, headers: [],
  125. oauth: { enabled: true, clientId: '', clientSecret: null, scope: 'read' }, command: '', args: [], env: [], allowNetwork: false, readPaths: [], writePaths: [],
  126. })
  127. expect(s.oauthStatus).toMatchObject({ authorized: false, registered: false })
  128. const refreshed = await app.mcpServers.refresh(s.id)
  129. expect(refreshed.lastError).toMatch(/需要 OAuth 授权/)
  130. const { authorizationUrl } = await app.mcpOAuth.start(s.id, base)
  131. const auth = new URL(authorizationUrl!)
  132. expect(auth.origin).toBe(origin)
  133. expect(auth.searchParams.get('client_id')).toBe('dyn-client')
  134. expect(auth.searchParams.get('redirect_uri')).toBe(`${base}/admin/v1/mcp/oauth/callback`)
  135. expect(auth.searchParams.get('code_challenge_method')).toBe('S256')
  136. expect(auth.searchParams.get('scope')).toBe('read')
  137. // 浏览器打开授权网址,授权服务器跳回回调地址(不带后台登录)
  138. const redirect = await fetch(authorizationUrl!, { redirect: 'manual' })
  139. const back = new URL(redirect.headers.get('location')!)
  140. const cb = await server.inject({ method: 'GET', url: `${back.pathname}${back.search}` })
  141. expect(cb.statusCode).toBe(200)
  142. expect(cb.body).toContain('授权成功')
  143. // state 只能用一次
  144. expect((await server.inject({ method: 'GET', url: `${back.pathname}${back.search}` })).body).toContain('授权未完成')
  145. const dto = app.mcpServers.get(s.id)
  146. expect(dto.oauthStatus).toMatchObject({ authorized: true, registered: true, lastError: null })
  147. expect(JSON.stringify(dto)).not.toMatch(/access_token|refresh_token|dyn-client/)
  148. await expect.poll(() => app.mcpServers.get(s.id).tools.map(t => t.name)).toContain('whoami')
  149. expect((await callWhoami(s.id)).content[0]!.text).toBe('account:alice')
  150. // 访问令牌失效:SDK 用刷新令牌续期后重试
  151. valid.clear()
  152. await app.mcpUpstream.close(s.id)
  153. expect((await callWhoami(s.id)).content[0]!.text).toBe('account:alice')
  154. expect(grants).toEqual(['authorization_code', 'refresh_token'])
  155. // 刷新令牌也失效:提示需要重新授权
  156. valid.clear()
  157. refreshTokens.clear()
  158. await app.mcpUpstream.close(s.id)
  159. const failed = await callWhoami(s.id)
  160. expect(failed.isError).toBe(true)
  161. expect(failed.content[0]!.text).toMatch(/需要 OAuth 授权/)
  162. expect(app.mcpServers.get(s.id).oauthStatus!.lastError).toMatch(/重新授权/)
  163. await app.mcpOAuth.revoke(s.id)
  164. expect(app.mcpServers.get(s.id).oauthStatus).toMatchObject({ authorized: false })
  165. })
  166. it('回调参数无效或授权被拒绝时给出说明', async () => {
  167. expect((await server.inject({ method: 'GET', url: '/admin/v1/mcp/oauth/callback?state=nope&code=x' })).body).toContain('授权请求无效或已过期')
  168. const id = app.mcpServers.list().find(x => x.name === 'secure')!.id
  169. const { authorizationUrl } = await app.mcpOAuth.start(id, base)
  170. const state = new URL(authorizationUrl!).searchParams.get('state')!
  171. const r = await server.inject({ method: 'GET', url: `/admin/v1/mcp/oauth/callback?state=${state}&error=access_denied&error_description=${encodeURIComponent('用户拒绝')}` })
  172. expect(r.body).toContain('授权未完成:用户拒绝')
  173. })
  174. })