MCP 安全之二:OAuth 2.1、资源指示符与渐进式授权


文档摘要

MCP 安全之二:OAuth 2.1、资源指示符与渐进式授权 本节摘要:远程 MCP 服务端需要的是「授权」而不仅仅是「认证」。2025-11-25 版规范与 OAuth 2.1 + PKCE + 资源指示符(RFC 8707)+ 受保护资源元数据(RFC 9728)对齐,SEP-835 又增加了带 403 步进授权的渐进式 scope 同意。本节把这套步进流实现成一台状态机,让你看清每一跳。读完本节,你能解释资源服务端与授权服务端各自的责任、走通 PKCE 保护的授权码流、用 和元数据挡住 confused-deputy(混淆代理)攻击,并在权限不足时让服务端触发客户端重新拉同意。

MCP 安全之二:OAuth 2.1、资源指示符与渐进式授权

本节摘要:远程 MCP 服务端需要的是「授权」而不仅仅是「认证」。2025-11-25 版规范与 OAuth 2.1 + PKCE + 资源指示符(RFC 8707)+ 受保护资源元数据(RFC 9728)对齐,SEP-835 又增加了带 403 WWW-Authenticate 步进授权的渐进式 scope 同意。本节把这套步进流实现成一台状态机,让你看清每一跳。读完本节,你能解释资源服务端与授权服务端各自的责任、走通 PKCE 保护的授权码流、用 resource 和元数据挡住 confused-deputy(混淆代理)攻击,并在权限不足时让服务端触发客户端重新拉同意。

学习目标

阅读完本节,你应当能够:

  1. 区分**资源服务端(Resource Server)授权服务端(Authorization Server)**各自的责任。
  2. 走通 PKCE 保护的 OAuth 2.1 授权码流,说清每一跳的作用。
  3. resource 参数(RFC 8707)与受保护资源元数据(RFC 9728)挡住 confused-deputy 攻击
  4. 实现步进授权(Step-up Authorization):服务端在 403 里回 WWW-Authenticate 索要更高 scope,客户端重新拉用户同意并重试。

一、问题与直觉

2025 年之前的早期 MCP 远程服务端,要么用临时拼凑的 API Key,要么干脆没鉴权。2025-11-25 版规范用一份完整的 OAuth 2.1 配置文件(Profile)补上了这道缝。

三类真实诉求驱动了这套设计:

  • 普通远程服务端:用户装一个要访问他 Notion / GitHub / Gmail 的远程 MCP 服务端,OAuth 2.1 + PKCE 是正确形状。
  • 权限升级:一个笔记服务端先拿到 notes:read,之后某个动作又需要 notes:write。与其把整套流程重来一遍,不如用步进(SEP-835)只追加那一个 scope。
  • 防混淆代理:客户端持有一张只对服务端 A 有效的 token,恶意服务端 A 想把它拿去给服务端 B 用。资源指示符(RFC 8707)把 token 钉死在它的目标受众上。

OAuth 2.1 本身不新。新的是 MCP 的配置文件:规定只允许授权码 + PKCE(禁止 implicit、默认禁用 client credentials)、每次 token 请求都必须带 resource、并发布受保护资源元数据让客户端知道去哪。

在 MCP 的配置文件里,资源服务端与授权服务端可以同主机,但应当用不同的 URL 区分。

二、从零实现

角色

  • 客户端(Client):MCP 客户端(Claude Desktop、Cursor 等)。
  • 资源服务端(Resource Server):MCP 服务端(notes、GitHub、Postgres……)。
  • 授权服务端(Authorization Server):签发 token 的服务,可以与资源服务端同体,也可以是独立的 IdP(Auth0、Keycloak、Cognito)。

授权码 + PKCE

六步的伪代码骨架:

# 步骤 1:PKCE 材料 code_verifier = secrets.token_urlsafe(64) code_challenge = base64url(sha256(code_verifier)) # 步骤 2:发起授权请求 auth_url = (f"{as_metadata['authorization_endpoint']}" f"?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URI}&scope=notes:read" f"&code_challenge={code_challenge}&code_challenge_method=S256" f"&resource={RESOURCE_URL}") # ← RFC 8707 钉受众 code = await open_browser_and_wait(auth_url) # 步骤 4:换 token token = httpx.post(as_metadata['token_endpoint'], data={ "grant_type": "authorization_code", "code": code, "code_verifier": code_verifier, "resource": RESOURCE_URL})

PKCE 防的是「授权码截获」:攻击者即便偷到 code,没有 code_verifier 也换不出 token。资源指示符防的是「token 在别处也有效」。

受保护资源元数据(RFC 9728)

资源服务端在 .well-known/oauth-protected-resource 发布一份文档:

{ "resource": "https://notes.example.com", "authorization_servers": ["https://auth.example.com"], "scopes_supported": ["notes:read", "notes:write", "notes:delete"] }

客户端从资源服务端发现授权服务端——配置减少,客户端只需要知道一个资源 URL。

资源指示符(RFC 8707)

token 请求里的 resource 参数把 token 的目标受众钉死。签发的 token 里带 aud: "https://notes.example.com"。别的 MCP 服务端收到这张 token 会校验 aud 并拒绝。

Scope 模型

scope 是空格分隔的字符串,常见约定:

  • notes:readnotes:writenotes:delete
  • admin:* 表示管理员能力(慎用)
  • profile:read 表示身份

scope 选择遵循最小权限:只请求当下需要的,需要更多时再步进。

步进授权(SEP-835)

用户先授予了 notes:read,后来让 Agent 删一条笔记。服务端返回:

HTTP/1.1 403 Forbidden WWW-Authenticate: Bearer error="insufficient_scope", scope="notes:delete", resource="https://notes.example.com"

客户端看到 insufficient_scope,弹出同意对话框追加 scope,跑一段只针对这个 scope 的迷你 OAuth 流,再用新 token 重试。

def handle_response(resp): if resp.status == 403 and "insufficient_scope" in resp.headers.get("WWW-Authenticate", ""): needed = parse_scope(resp.headers["WWW-Authenticate"]) token = step_up_consent(needed) # 重新拉用户同意 return retry_with(token) return resp

受众校验

每次请求:服务端检查 token.aud == self.resource_url,不匹配就 401。这挡住了跨服务端 token 复用。

短期 token 与轮换

access token 应当短命(默认 1 小时)。refresh token 每次刷新都轮换。客户端在后台静默刷新。

禁止 token 透传

采样服务端(第 11 节)绝不能把客户端的 token 透传给其他服务。采样请求就是边界。

Confused-deputy 防御

token 绑 aud,客户端绑 client_id,每次请求两者都校验。规范明确禁止了 MCP 前远程工具生态里常见的「pass-the-token」旧模式。

客户端 ID 发现

每个 MCP 客户端在一个固定 URL 发布自己的元数据,授权服务端可以拉取它来发现 redirect URI 与联系方式,免去了手动注册。

网关与 OAuth

第 17 节演示企业网关如何处理 OAuth:网关持有上游服务端的凭证,给客户端发的 token 由网关签发,上游 token 永不离开网关。这翻转了信任模型——用户只在网关认证一次,网关处理 N 个服务端的授权。

状态机实现思路

把整个流抽象成一台状态机,状态包括 INITAUTHORIZINGEXCHANGINGCALLINGSTEP_UP_NEEDEDDONE:

def run_flow(state): while state.phase != "DONE": if state.phase == "INIT": state = gen_pkce(state) elif state.phase == "AUTH": state = do_authorize(state) elif state.phase == "EXCHANGE": state = exchange_token(state) elif state.phase == "CALL": state = call_resource(state) elif state.phase == "STEP_UP": state = step_up(state) return state.result

设计要点:用状态机而非线性的「一锤子」函数,是因为步进授权会在中途回环——CALL 阶段可能被 403 打回 STEP_UP,再回到 CALL。线性流程难以表达这种回环。

三、框架对比

机制 防截获授权码 防跨服务端复用 支持渐进授权 适用
仅 client_secret 已过时,服务端客户端
PKCE(S256) 所有公共客户端(强制)
+ 资源指示符(RFC 8707) 多服务端环境
+ SEP-835 步进授权 按需升级 scope
+ 受保护资源元数据(9728) 零配置发现

💡 心法:OAuth 2.1 配置文件不是「选一个」,而是层层叠加——PKCE 是基线,资源指示符钉受众,元数据做发现,步进流处理渐进授权。少了任何一层,攻击面就暴露出来。

四、可复用产物

本节产出 outputs/skill-oauth-scope-planner.md——给定一个带工具的远程 MCP 服务端,这个 skill 为你设计 scope 集合、钉住规则与步进策略。

code/main.py 把完整的 OAuth 2.1 步进流实现成内存状态机,涵盖:PKCE 的 verifier/challenge 生成、带资源指示符的授权码流、受保护资源元数据端点、带受众校验的 token 校验、insufficient_scope 触发的步进。本节不开 HTTP 服务端,状态机在内存里跑,你能逐步追踪每一跳——真正的传输层对接放到第 17 节网关课。

五、练习

  1. 跑通步进流:运行 code/main.py,追踪两段 scope 的步进流,留意哪些跳在步进时会重复。

  2. 加 refresh token 轮换:每次刷新签发新的 refresh token 并作废旧的,模拟一个被盗的 refresh token 在轮换后被使用,确认它失败。

  3. 真实端点:用 stdlib http.server 把受保护资源元数据端点实现成真实 HTTP 响应,镜像第 09 节的 /mcp 端点。

  4. 设计 scope 层级:为 GitHub MCP 服务端设计 scope 层级——读仓库、写 PR、批准 PR、合并 PR、管理员,在每一级之间用步进。

  5. 读 RFC:精读 RFC 8707 与 RFC 9728,找出 9728 里 MCP 用法与 RFC 示例不同的那个字段(提示:与 scopes_supported 有关)。

本节要点回顾

  1. 远程 MCP 服务端要授权而非仅认证:2025-11-25 规范用完整的 OAuth 2.1 配置文件取代了临时 API Key。
  2. 三种角色:客户端、资源服务端(MCP 服务端)、授权服务端(IdP);资源与授权可同主机但应 URL 分离。
  3. PKCE 是基线:verifier + challenge 防授权码截获,公共客户端靠它做占有证明。
  4. 资源指示符(RFC 8707)钉受众:resource 参数让 token 的 aud 绑死单个服务端,挡 confused-deputy。
  5. 受保护资源元数据(RFC 9728):客户端从一个资源 URL 出发就能发现授权服务端,零配置。
  6. 步进授权(SEP-835):服务端用 403 + WWW-Authenticate 索要更高 scope,客户端重新拉同意并重试,无需重跑全流程。
  7. 每次请求都校验 aud:这是协议层挡跨服务端复用的唯一手段,不能为性能省略。
  8. 禁止 token 透传:采样请求是信任边界,客户端的 token 不能被服务端转手给第三方。

下一节,我们看企业如何把这层鉴权集中化——MCP 网关与官方注册中心:RBAC、审计、限流、工具哈希钉住,以及反向 DNS 命名的可信上游。


发布者: 作者: Rohit Gupta 转发
评论区 (0)
U