AI 技术
#MCP#OAuth 2.1#令牌受众#资源指示器#授权

MCP 远程服务器的 OAuth 令牌受众校验:为什么为 A 服务签发的令牌能调用 B 服务

多台远程 MCP Server 共用同一授权服务器时,为 A 服务签发的访问令牌常能直接调用 B 服务。本文解释 RFC 8707 资源指示器与 aud 绑定机制,分析授权服务器未按资源签发、客户端复用令牌、资源服务器漏校验 aud 三类成因,对比静态 API Key 与逐服务令牌方案,并给出校验逻辑与可观测指标。

一个能复现的越权调用

设想一家公司把三台远程 MCP Server 挂在同一个授权服务器下:知识库检索服务、代码仓库服务、财务报销服务。员工在 MCP 客户端里先登录知识库,拿到一个访问令牌。客户端随后把同一个令牌发给财务服务的 MCP 端点,请求调用“提交报销单”工具。财务服务返回了成功,而不是 401。

这个结果违反直觉。用户只授权了知识库,财务服务却接受了他的请求。问题不在网络,也不在 TLS,而在令牌本身:财务服务没有检查这个令牌是不是为它签发的。

OAuth 2.0 的访问令牌默认是持有者令牌(bearer token)。RFC 8707 明确指出,任何持有该令牌的一方都能用它访问相关资源。要阻止误用,必须满足一个前提:令牌只对特定受保护资源、特定访问范围有效。当授权服务器不知道客户端打算把令牌用在哪里,它就无法把令牌的受众限制到那个资源。

本文以“三台 MCP Server 共用同一授权服务器”为贯穿场景,说明受众绑定为什么失效、如何校验、以及生产环境该观察哪些信号。

基线做法为什么会漏

授权服务器不知道令牌要去哪

在没有资源指示器的情况下,客户端向授权服务器发授权请求,只带 client_id、scope、redirect_uri 等参数。授权服务器看到 scope 是 kb:read,就按这个范围签发令牌。它不知道这个令牌接下来会被送到知识库、代码仓库还是财务服务。

RFC 8707 的引言把这个问题讲得很清楚:当授权服务器服务大量不同资源时,客户端需要显式告诉它令牌将用于哪里。知道接收方之后,授权服务器才能按该实体构造令牌,例如把令牌内容加密给特定资源,或按该资源的数据要求生成令牌。

scope 不能替代资源身份

一种常见做法是拿 scope 编码服务位置,比如 finance:submit。RFC 8707 指出这种做法并不总是可行或合适。scope 通常表达“请求什么访问”,而不是“在哪里兑换这个访问”。email、admin:org、user_photos、channels:read 这类 scope 值只传达访问类型,不传达位置或身份。

把服务名塞进 scope 会带来两个后果。第一,scope 命名空间随服务数量膨胀,授权服务器和客户端都要维护映射。第二,即便 scope 里写了 finance,如果资源服务器不校验令牌受众,另一个服务仍可能接受这个令牌。scope 是权限声明,不是接收方声明。

资源服务器默认信任任何有效令牌

许多 MCP Server 的实现只做两件事:验证令牌签名、检查是否过期。签名有效且未过期,就放行。这种校验回答了“令牌是不是这个授权服务器签的”,但没有回答“这个令牌是不是签给我用的”。

RFC 8707 引用了 RFC 6750 第 5.2 节的要求:应在令牌中包含预期接收方,以防止令牌重定向。如果资源服务器不检查这个接收方,令牌重定向就无人拦截。

资源指示器与 aud 绑定机制

resource 参数如何流动

RFC 8707 定义了一个 resource 请求参数。客户端在向授权服务器发请求时带上它,用来指示正在请求访问的目标服务或资源。该参数的值必须是一个绝对 URI,不得包含片段组件;通常不应包含查询组件,但在某些场景下查询组件是必要且有用的。

MCP 规范对这一点有强制要求:MCP 客户端必须实现 RFC 8707 资源指示器,显式指定令牌所请求的目标资源。MCP 服务器必须实现 RFC 9728 受保护资源元数据,客户端必须用它来发现授权服务器。

在贯穿场景里,流程是这样:客户端要连财务 MCP Server,先向该服务器的元数据端点取回受保护资源元数据,从中读到 authorization_servers 字段,得知共用授权服务器的地址。然后客户端在授权请求里带上 resource=https://finance.example.com/mcp。授权服务器据此签发一个受众为财务服务的令牌。

aud 声明承载接收方

授权服务器把 resource 值映射为令牌里的受众声明。在 JWT 形式的访问令牌中,这个声明就是 aud。资源服务器收到令牌后,取出 aud,与自己的资源标识符比对。相等才放行。

MCP 规范在安全考量里专门列了“令牌受众绑定与校验”一节,说明受众绑定是防止令牌被挪用到其他服务的关键。RFC 9728 定义了受保护资源的资源标识符,它是一个使用 http 或 https 方案的 URL,资源服务器据此发布自己的元数据。

这里有一个容易踩的坑:资源标识符和实际请求 URL 可能不同。财务服务的资源标识符可能是 https://finance.example.com/mcp,而客户端实际请求的路径可能带查询参数或子路径。校验时应该比对规范化后的资源标识符,而不是拿原始请求 URL 做字符串相等。

校验发生在哪一层

受众校验属于资源服务器的职责,发生在令牌验证之后、执行业务逻辑之前。它不依赖授权服务器端的额外配合,只要令牌里带了 aud,资源服务器就能自行判断。

下面用流程图展示一次越权调用是如何被拦下的。

flowchart TD
    A[客户端已持有知识库令牌] --> B[向财务 MCP 端点发请求]
    B --> C[财务服务验证令牌签名与有效期]
    C -->|通过| D[读取令牌 aud 声明]
    D --> E{aud 是否等于财务资源标识符}
    E -->|否| F[返回 401 并附带 WWW-Authenticate]
    E -->|是| G[检查 scope 是否覆盖所需工具]
    G -->|通过| H[执行提交报销单工具]
    G -->|不足| I[返回 403 insufficient_scope]

关键转折点在 E 节点。签名校验通过不代表可以放行,因为签名只证明令牌来源,不证明令牌归属。很多实现恰恰跳过了这一步。

三类失效成因

授权服务器未按资源签发

授权服务器收到授权请求后,如果忽略 resource 参数,或者虽然收到但没有把它写进令牌的 aud,签出的令牌就没有接收方信息。此时资源服务器即使想校验也无从校验。

这种失效通常有几个诱因:授权服务器版本较旧,不支持 RFC 8707;或者支持但配置里没有开启资源指示器处理;或者客户端根本没传 resource,授权服务器只能签一个受众宽泛的令牌。MCP 规范要求客户端必须传,所以第三种情况属于客户端实现缺陷。

客户端复用令牌

客户端可能把为一个服务获取的令牌缓存起来,连另一个服务时直接复用。在贯穿场景里,客户端先连知识库拿到令牌,再连财务服务时复用了同一个令牌,而不是重新走一次带 resource 的授权流程。

Cloudflare 的 MCP 客户端实现文档提到,其 MCPClientManager 为每个连接维护独立的认证上下文,允许一个智能体同时对多个服务器完成认证。这说明多服务器场景下,逐连接维护认证状态是可行的工程做法。客户端复用一个令牌,等于把知识库的授权范围扩散到了财务服务。

资源服务器不校验 aud

这是最直接的一环。资源服务器验证了签名和有效期就放行,没有读取 aud。即便授权服务器正确签发了带受众的令牌,这一步缺失也会让绑定失效。

MCP 规范在安全考量中把“令牌受众绑定与校验”单列,正是因为资源服务器端的校验不能省略。签名验证和受众校验回答的是两个不同问题,前者防伪造,后者防挪用。

方案对比与权衡

静态 API Key 是最简单的替代方案。每台 MCP Server 发一个长期密钥,客户端按服务配置。它不依赖授权服务器,实现成本低。代价是密钥长期有效、难以按用户粒度撤销、无法表达用户级授权,而且一旦泄露,影响范围覆盖该密钥对应的全部权限。

逐服务令牌方案要求客户端为每个 MCP Server 单独走授权流程,令牌受众绑定到该服务。它的授权粒度更细,能按用户、按服务、按 scope 撤销。代价是客户端要管理多个令牌的生命周期,授权服务器要正确处理 resource 参数,资源服务器要校验 aud。

下表对比三种形态在贯穿场景下的表现。

维度静态 API Key共用令牌不校验 aud逐服务令牌并校验 aud
令牌接收方绑定无,密钥即权限无,令牌可跨服务使用有,aud 绑定到具体资源
越权调用风险密钥泄露即全量暴露高,A 服务令牌可调 B 服务低,跨服务调用被 401 拦截
撤销粒度按密钥,影响该密钥全部权限按令牌,但受众宽泛按用户、按服务、按 scope
客户端复杂度低,配置密钥即可低,复用令牌中,需按服务管理授权流程
授权服务器要求无无需支持 RFC 8707 资源指示器
资源服务器要求校验密钥校验签名与有效期校验签名、有效期与 aud
用户级授权不支持部分支持支持

这张表里最后两行的差异决定了方案能否落地。逐服务令牌方案要求授权服务器和资源服务器同时支持受众绑定,任何一端缺失,绑定都会退化。

实现校验与可观测指标

资源服务器侧的校验顺序

资源服务器收到请求后,按固定顺序校验,任一步失败立即拒绝。

第一步,验证令牌签名,确认由可信授权服务器签发。第二步,检查 exp 和 nbf,确认令牌在有效期内。第三步,读取 aud,与自身的资源标识符比对。第四步,检查 scope 是否覆盖被调用工具所需的权限。

第三步是本文重点。比对时要注意 aud 可能是字符串或数组。RFC 8707 允许客户端一次请求多个资源,此时 aud 可能是数组。资源服务器应判断自身资源标识符是否在数组内,而不是要求数组只有一个元素。

// 伪代码:资源服务器受众校验
function authorize(request, token, myResourceId):
    if not verifySignature(token):
        return reject(401, "invalid_token")
    if isExpired(token) or notYetValid(token):
        return reject(401, "invalid_token")
    audiences = normalizeAudience(token.aud)
    if myResourceId not in audiences:
        return reject(401, "invalid_token")
    if not scopeCovers(token.scope, request.tool):
        return reject(403, "insufficient_scope")
    return allow()

这段逻辑里,normalizeAudience 负责把字符串或数组统一成集合,myResourceId 是资源服务器在 RFC 9728 元数据里声明的资源标识符。两者必须来自同一套配置,否则会出现“自己签的令牌自己都不认”的故障。

客户端侧的令牌隔离

客户端应按 MCP Server 隔离令牌存储,键可以是服务器标识符或资源标识符。连新服务器时,先查是否有该资源的有效令牌,没有就重新走带 resource 的授权流程。不要用“当前用户已登录”作为复用令牌的依据。

需要观察的信号

受众校验失败应该产生可区分的错误,而不是笼统的 401。建议在日志和指标里记录以下内容。

  • 按资源标识符统计的受众校验失败次数。持续增长说明有客户端在复用令牌。
  • 授权请求里 resource 参数的缺失率。缺失率高说明客户端未实现 RFC 8707。
  • 签出令牌的 aud 分布。如果大量令牌 aud 为空或为宽泛值,说明授权服务器未按资源签发。
  • 同一令牌在不同资源标识符上的出现次数。跨资源出现即越权信号。
  • 401 响应中 WWW-Authenticate 头的返回率。MCP 规范要求服务器在返回 401 时用该头指示资源元数据 URL。

这些指标能区分三类成因:resource 缺失率指向客户端,aud 分布指向授权服务器,受众校验失败次数指向资源服务器或复用行为。

失效边界与未决问题

受众校验不是万能的。它依赖授权服务器正确签发带 aud 的令牌。如果授权服务器不支持 RFC 8707,资源服务器拿不到可校验的受众信息,只能退回到“校验签名加有效期”的弱校验。此时越权风险仍然存在,需要靠客户端隔离令牌来缓解。

另一个边界是资源标识符的规范化。同一台服务器可能通过多个 URL 访问,比如带不带尾斜杠、走不走网关。如果资源标识符配置不一致,合法请求会被误拒。生产环境应固定一套资源标识符,并在网关层做重写,避免多套标识符并存。

仍未解决的问题包括:多资源令牌的受众数组如何与 scope 精确对应,目前规范没有给出强绑定;令牌内省(token introspection)场景下 aud 如何传递,需要资源服务器与授权服务器约定;以及当 MCP Server 动态注册、资源标识符频繁变化时,客户端缓存如何失效。这些边界在部署前需要结合自身授权服务器能力逐项确认。

资料来源

  1. RFC 8707 Resource Indicators for OAuth 2.0
  2. RFC 9728 OAuth 2.0 Protected Resource Metadata
  3. Model Context Protocol Specification: Authorization
  4. 拼合智能体技术版图:MCP 协议、身份验证和授权以及 Durable Objects 免费计划 | Cloudflare 博客