MCP 安全之二:OAuth 2.1、资源指示符与渐进式授权 本节摘要:远程 MCP 服务端需要的是「授权」而不仅仅是「认证」。2025-11-25 版规范与 OAuth 2.1 + PKCE + 资源指示符(RFC 8707)+ 受保护资源元数据(RFC 9728)对齐,SEP-835 又增加了带 403 步进授权的渐进式 scope 同意。本节把这套步进流实现成一台状态机,让你看清每一跳。读完本节,你能解释资源服务端与授权服务端各自的责任、走通 PKCE 保护的授权码流、用 和元数据挡住 confused-deputy(混淆代理)攻击,并在权限不足时让服务端触发客户端重新拉同意。
本节摘要:远程 MCP 服务端需要的是「授权」而不仅仅是「认证」。2025-11-25 版规范与 OAuth 2.1 + PKCE + 资源指示符(RFC 8707)+ 受保护资源元数据(RFC 9728)对齐,SEP-835 又增加了带 403
WWW-Authenticate步进授权的渐进式 scope 同意。本节把这套步进流实现成一台状态机,让你看清每一跳。读完本节,你能解释资源服务端与授权服务端各自的责任、走通 PKCE 保护的授权码流、用resource和元数据挡住 confused-deputy(混淆代理)攻击,并在权限不足时让服务端触发客户端重新拉同意。
阅读完本节,你应当能够:
resource 参数(RFC 8707)与受保护资源元数据(RFC 9728)挡住 confused-deputy 攻击。WWW-Authenticate 索要更高 scope,客户端重新拉用户同意并重试。2025 年之前的早期 MCP 远程服务端,要么用临时拼凑的 API Key,要么干脆没鉴权。2025-11-25 版规范用一份完整的 OAuth 2.1 配置文件(Profile)补上了这道缝。
三类真实诉求驱动了这套设计:
notes:read,之后某个动作又需要 notes:write。与其把整套流程重来一遍,不如用步进(SEP-835)只追加那一个 scope。OAuth 2.1 本身不新。新的是 MCP 的配置文件:规定只允许授权码 + PKCE(禁止 implicit、默认禁用 client credentials)、每次 token 请求都必须带 resource、并发布受保护资源元数据让客户端知道去哪。
在 MCP 的配置文件里,资源服务端与授权服务端可以同主机,但应当用不同的 URL 区分。
六步的伪代码骨架:
# 步骤 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 在别处也有效」。
资源服务端在 .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。
token 请求里的 resource 参数把 token 的目标受众钉死。签发的 token 里带 aud: "https://notes.example.com"。别的 MCP 服务端收到这张 token 会校验 aud 并拒绝。
scope 是空格分隔的字符串,常见约定:
notes:read、notes:write、notes:deleteadmin:* 表示管理员能力(慎用)profile:read 表示身份scope 选择遵循最小权限:只请求当下需要的,需要更多时再步进。
用户先授予了 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 复用。
access token 应当短命(默认 1 小时)。refresh token 每次刷新都轮换。客户端在后台静默刷新。
采样服务端(第 11 节)绝不能把客户端的 token 透传给其他服务。采样请求就是边界。
token 绑 aud,客户端绑 client_id,每次请求两者都校验。规范明确禁止了 MCP 前远程工具生态里常见的「pass-the-token」旧模式。
每个 MCP 客户端在一个固定 URL 发布自己的元数据,授权服务端可以拉取它来发现 redirect URI 与联系方式,免去了手动注册。
第 17 节演示企业网关如何处理 OAuth:网关持有上游服务端的凭证,给客户端发的 token 由网关签发,上游 token 永不离开网关。这翻转了信任模型——用户只在网关认证一次,网关处理 N 个服务端的授权。
把整个流抽象成一台状态机,状态包括 INIT、AUTHORIZING、EXCHANGING、CALLING、STEP_UP_NEEDED、DONE:
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 节网关课。
跑通步进流:运行 code/main.py,追踪两段 scope 的步进流,留意哪些跳在步进时会重复。
加 refresh token 轮换:每次刷新签发新的 refresh token 并作废旧的,模拟一个被盗的 refresh token 在轮换后被使用,确认它失败。
真实端点:用 stdlib http.server 把受保护资源元数据端点实现成真实 HTTP 响应,镜像第 09 节的 /mcp 端点。
设计 scope 层级:为 GitHub MCP 服务端设计 scope 层级——读仓库、写 PR、批准 PR、合并 PR、管理员,在每一级之间用步进。
读 RFC:精读 RFC 8707 与 RFC 9728,找出 9728 里 MCP 用法与 RFC 示例不同的那个字段(提示:与 scopes_supported 有关)。
resource 参数让 token 的 aud 绑死单个服务端,挡 confused-deputy。WWW-Authenticate 索要更高 scope,客户端重新拉同意并重试,无需重跑全流程。aud:这是协议层挡跨服务端复用的唯一手段,不能为性能省略。下一节,我们看企业如何把这层鉴权集中化——MCP 网关与官方注册中心:RBAC、审计、限流、工具哈希钉住,以及反向 DNS 命名的可信上游。