MCP 生产鉴权:客户端注册、JWKS 刷新、受众钉住的 token 本节摘要:第 16 节把 OAuth 2.1 的状态机在内存里立了起来。到了 2026 年,你交付给真实组织的每个 MCP 服务端背后都得顶着生产级鉴权——能扩展到无限客户端群体的客户端注册(首选客户端 ID 元数据文档 CIMD,RFC 7591 动态注册作为向后兼容的回退)、授权服务端元数据发现(RFC 8414 或 OpenID Connect Discovery)、不会在凌晨三点让 token 校验崩掉的 JWKS 缓存刷新,以及拒绝跨资源重放的受众钉住 token。本节用三个角色——授权服务端、资源服务端(MCP 服务端)、客户端——把整张面建模出来,让你追踪从发现到一次受校验的工具调用之间的每一跳。
本节摘要:第 16 节把 OAuth 2.1 的状态机在内存里立了起来。到了 2026 年,你交付给真实组织的每个 MCP 服务端背后都得顶着生产级鉴权——能扩展到无限客户端群体的客户端注册(首选客户端 ID 元数据文档 CIMD,RFC 7591 动态注册作为向后兼容的回退)、授权服务端元数据发现(RFC 8414 或 OpenID Connect Discovery)、不会在凌晨三点让 token 校验崩掉的 JWKS 缓存刷新,以及拒绝跨资源重放的受众钉住 token。本节用三个角色——授权服务端、资源服务端(MCP 服务端)、客户端——把整张面建模出来,让你追踪从发现到一次受校验的工具调用之间的每一跳。
规范说明(2025-11-25):2025 年 11 月的 MCP 授权规范把动态客户端注册(DCR)从
SHOULD降级为MAY,并把**客户端 ID 元数据文档(CIMD)**作为推荐的默认注册机制。本节按规范的优先级顺序讲这两者,代码走查里仍保留 DCR,因为它在一个进程里完全自洽。
阅读完本节,你应当能够:
第 16 节的模拟器在内存里跑 OAuth 2.1。生产环境有三个模拟器看不到的运营缺口。
第一个缺口是注册。一个真实组织跑着几百个 MCP 服务端、几千个 MCP 客户端。运维不会把每个 Cursor 用户都手工注册成 OAuth 客户端。2025-11-25 规范给了客户端一条优先级顺序:有预注册 client_id 就用;否则用客户端 ID 元数据文档(CIMD)(客户端用一个自己控制的 HTTPS URL 作为 client_id,授权服务端去拉元数据);否则回退到 RFC 7591 动态客户端注册(DCR)(客户端推送一个 POST /register,当场拿回 client_id);否则提示用户。CIMD 是推荐默认,因为它完全去掉了逐服务端注册,同时保持以 DNS 为根的信任模型;DCR 为向后兼容保留。两者都从授权服务端的元数据发现自己的入口:client_id_metadata_document_supported(CIMD)、registration_endpoint(DCR)。
第二个缺口是密钥轮换。JWT 校验依赖授权服务端的签名密钥,以 JSON Web Key Set(JWKS)形式发布。授权服务端按计划轮换(通常每小时,事故响应下可能更快)。一个只在启动时拉一次 JWKS 的 MCP 服务端,在轮换窗口之前校验都正常,之后每个请求都失败,直到重启。生产里要把 JWKS 接成一个带刷新任务的缓存值,在旧密钥过期前覆盖缓存,并对缓存未命中的情况加一次回退拉取——因为可能来了一个用比缓存更新的密钥签的 token。
第三个缺口是受众绑定。第 16 节引入了 RFC 8707 资源指示符。在生产里,它变成每次请求都要做的硬声明校验。MCP 服务端把 token.aud 与自己的规范资源 URL 对比,不匹配就 HTTP 401。这是应对上游 MCP 服务端(或一个持着为某个服务端签发 token 的恶意客户端)把那张 token 重放给同一信任网格里另一个服务端的唯一防线。
本节把每个缺口映射到面上一块具体的东西:元数据文档是个 HTTP 端点;JWKS 缓存刷新是个定时任务加键值缓存;JWT 校验是资源服务端在派发任何工具前跑的一套例程。三个角色各司其职:授权服务端签发并轮换密钥,资源服务端缓存并校验,客户端发现并注册。
/.well-known/oauth-authorization-server 上的一份文档,描述客户端需要的一切:
{ "issuer": "https://auth.example.com", "authorization_endpoint": "https://auth.example.com/authorize", "token_endpoint": "https://auth.example.com/token", "jwks_uri": "https://auth.example.com/.well-known/jwks.json", "registration_endpoint": "https://auth.example.com/register", "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token"], "code_challenge_methods_supported": ["S256"], "scopes_supported": ["mcp:tools.read", "mcp:tools.invoke"], "token_endpoint_auth_methods_supported": ["none", "private_key_jwt"] }
拿到一个 MCP 资源 URL 的客户端,会链式发现:先从 RFC 9728 的 oauth-protected-resource(资源服务端的文档)拿到 issuer,再从 oauth-authorization-server(本 RFC)拿到所有端点。客户端绝不硬编码授权 URL。
签下这份契约前,你要校验:
code_challenge_methods_supported 含 S256(RFC 7636 的 PKCE)。规范明确:若该字段缺失,授权服务端就不支持 PKCE,客户端必须拒绝继续。grant_types_supported 含 authorization_code,拒绝 password 与 implicit。client_id_metadata_document_supported: true(CIMD,优先)或 registration_endpoint(RFC 7591 DCR,回退)。任一即满足契约;你不再硬要求 DCR。response_types_supported 对 OAuth 2.1 恰为 ["code"]。若 S256 缺失,MCP 服务端拒绝对该 IdP 部署——PKCE 无降级模式。若两条注册路径都没广告、你也没有预注册的 client_id,你也无法注册;错的不是代码,是部署清单。
第 16 节讲过。生产里的增量:这份文档是客户端找到「这个 MCP 服务端信任的授权服务端」的唯一去处。单个 MCP 服务端可能接受来自多个 IdP 的 token(一个面向员工,一个面向合作伙伴)。RFC 9728 声明这组 IdP,RFC 8414 文档化每个 IdP 支持什么。
CIMD 把注册从推送翻成拉取。客户端不请授权服务端铸造 client_id,而是用一个自己控制的 HTTPS URL 作为 client_id。该 URL 解析到一份 JSON 元数据文档;授权服务端在 OAuth 流程中按需去拉。信任根植于 DNS:如果服务端运营者信任 app.example.com,它就信任从 https://app.example.com/client.json 服务的客户端。没有注册往返、没有会耗尽的 client_id 命名空间、没有需要同步的逐服务端状态。
客户端托管的元数据文档:
{ "client_id": "https://app.example.com/oauth/client.json", "client_name": "Example MCP Client", "client_uri": "https://app.example.com", "redirect_uris": ["http://127.0.0.1:7333/callback", "http://localhost:7333/callback"], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none" }
文档里的 client_id 值必须等于它被服务所在的 URL(授权服务端会校验;不匹配即拒)。授权服务端用 RFC 8414 元数据里的 client_id_metadata_document_supported: true 广告支持。
两个安全事实,规范说得很直白:
localhost redirect。授权服务端必须在同意时清楚显示 redirect URI 主机名,应当对仅 localhost 的 redirect 发警告。CIMD 无需服务端状态,不像 DCR 要立一个注册器。客户端侧只读:从一个静态 HTTPS 端点服务你的元数据文档,让授权服务端来拉。
DCR 现在是 MAY,为兼容 2025-11-25 之前的部署和尚未支持 CIMD 的 IdP 保留。没有它(也没有 CIMD 或预注册)时,每个 MCP 客户端(Cursor、Claude Desktop、自定义 Agent)都得跟 IdP 管理员做带外交换。有 DCR 时,客户端 POST:
POST /register Content-Type: application/json { "redirect_uris": ["http://127.0.0.1:7333/callback"], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none", "scope": "mcp:tools.invoke", "client_name": "Cursor", "software_id": "com.cursor.cursor", "software_version": "0.42.0" }
服务端回 client_id 与一个用于后续更新的 registration_access_token。token_endpoint_auth_method: none 对跑在用户设备上的 MCP 客户端是正确默认——只拿 client_id,没有会外泄的 client_secret。PKCE 提供公共客户端需要的占有证明。
三个生产陷阱:
client_id 命名空间。software_statement(一个为客户端背书的签名 JWT)。本节 mock 跳过;生产要加校验步骤,对除 localhost redirect 之外的未签名注册一律拒。registration_access_token 必须以哈希存储,不能明文。它一旦泄露,攻击者就能改写客户端的 redirect URI。第 16 节定了形状。生产规则:每次 token 请求都带 resource=<规范 MCP URL>,MCP 服务端每次调用都校验 token.aud 与自己的资源 URL 匹配。规范 URI 是该服务端最具体的标识:小写 scheme 与 host、无 fragment、按约定无尾斜杠。路径组件不按规则剥离——需要时用来标识单个 MCP 服务端。https://mcp.example.com、https://mcp.example.com/mcp、https://mcp.example.com:8443、https://mcp.example.com/server/mcp 都是合法规范 URI。每个服务端选一个,把 aud 钉到它。
OAuth 2.1 里 PKCE 强制。本节的授权码流始终带 code_challenge 与 code_verifier。服务端拒绝任何无 verifier 或 verifier 哈希不匹配存储 challenge 的 token 请求。
MCP 规范(2025-11-25)对 MCP 服务端授权层该做什么写得很精确:
WWW-Authenticate: Bearer resource_metadata="..." 头或知名 URI /.well-known/oauth-protected-resource 提供其位置(SEP-985 让头变成可选、有知名回退)。元数据的 authorization_servers 字段必须命名至少一个服务端。Authorization: Bearer ... 接受 token,每次请求——绝不在查询串里,绝不仅在校验在会话开始时做一次。aud、iss、exp 与所需 scope。服务端必须校验 token 是专门为它签发的(受众);缺失或不匹配的 aud 被拒,绝不作通配。WWW-Authenticate: Bearer,携带 error=...、resource_metadata="<PRM-URL>" 参数(元数据文档的 URL,不是裸资源),以及 insufficient_scope(403)时带 scope="..."。注意:这个参数叫 resource_metadata,是一个发现指针——challenge 里没有 resource 参数。issuer,兑换 code 前校验授权响应的 iss 参数(RFC 9207)。光靠 PKCE 挡不住 mix-up,因为客户端会把它的 code_verifier 交给被引向的任何 token 端点。OAuth 2.1 草案是基底;RFC 8414/7591/8707/9728/9207 + RFC 7636 + CIMD 是表面;MCP 规范是配置。
并非每个 IdP 都支持完整 MCP 配置。下表是截至 2025-11-25 规范的事实陈述,是部署闸门,不是推荐。
| IdP 类别 | AS 元数据(8414/OIDC) | CIMD | RFC 7591 DCR | RFC 8707 资源 | RFC 7636 S256 PKCE | 备注 |
|---|---|---|---|---|---|---|
| 自托管(Keycloak) | 是 | 出现中 | 是 | 是(24.x 起) | 是 | 本节 MCP 配置的参考 IdP;端到端 DCR 全通,CIMD 跟进新规范 |
| 企业 SSO(Microsoft Entra ID) | 是 | 出现中 | 是(高端租户层) | 是 | 是 | DCR 可用性因租户层而异;部署前在目标租户核实 |
| 企业 SSO(Okta) | 是 | 出现中 | 是(Okta CIC / Auth0) | 是 | 是 | DCR 在 Auth0(现 Okta CIC)可用;经典 Okta 组织需管理员预注册 |
| 社交登录 IdP(通用) | 不定 | 否 | 罕见 | 罕见 | 是 | 多把客户端当静态伙伴;无自助注册。仅作身份源,在上层叠自己的 MCP 感知授权服务端 |
| 自研 | 不定 | 不定 | 不定 | 不定 | 不定 | 自研就做全配置,优先 CIMD。跳过 PKCE 或受众绑定会破坏 MCP 授权契约 |
部署清单的拒绝规则:选定的 IdP 不在 code_challenge_methods_supported 列 S256,MCP 服务端拒绝启动——PKCE 无降级模式。注册是更软的闸门:需要一条可用路径(预注册的 client_id、client_id_metadata_document_supported: true 或 registration_endpoint)。光 DCR 缺失不再触发拒绝,因为 CIMD 或预注册可顶上。
把两个动词分开,因为混它们是真生产 bug:
GET 已发布的 JWKS 进缓存。这是资源服务端唯一能做的 JWKS 动作。生产的失败模式是缓存变陈。用定时刷新任务加键值缓存解决。资源服务端跑一个任务(cron、定时器……),按固定间隔拉 <issuer>/.well-known/jwks.json 并覆盖 cache[issuer] = {keys, fetched_at}。校验器读这个缓存。kid 不在缓存里的 token 触发一次同步刷新作回退,然后重查。一次同时处理两种情况:计划刷新,以及一把全新密钥签的 token 在下次计划刷新前到达的重叠窗口。
回退必须是重新拉取,绝不能是轮换。如果你把缓存未命中路径接到「轮换并铸造」,两件事会坏:① 铸一把新密钥产出的 kid 仍不匹配 token,查找照样失败;② 攻击者用随机 kid 喷 token,逼出无界的密钥创建——自找的 DoS。重新拉取是幂等的,假 kid 最多浪费一次拉取。
缓存形状:
{ "https://auth.example.com": { "keys": [ {"kid": "k_2026_03", "kty": "RSA", "n": "...", "e": "AQAB", "alg": "RS256", "use": "sig"}, {"kid": "k_2026_04", "kty": "RSA", "n": "...", "e": "AQAB", "alg": "RS256", "use": "sig"} ], "fetched_at": 1772668800 } }
同时两把密钥是稳态。授权服务端先引入下一把(k_2026_04)再退役上一把(k_2026_03),旧密钥签的 token 在过期前仍有效。缓存持有并集,校验器按 kid 选。
MCP 服务端在派发任何工具前跑校验。code/main.py 用的形状:
result = server.validate(bearer_token, required_scope="mcp:tools.invoke") if not result["valid"]: return {"status": result["status"], "WWW-Authenticate": result["www_authenticate"]}
validate 解码 JWT、从 JWKS 缓存(未命中时刷新一次)解析签名密钥、验签,然后依次查 iss(对照 allow-list)、aud(对照本服务端的规范资源)、exp、所需 scope——首次失败就返回一个 WWW-Authenticate challenge。把它放在资源服务端的单一例程里,意味着每个入口(每个工具调用、每种传输)都走同样校验;没有不先校验就到工具的路径。
服务端 A(notes.example.com)与服务端 B(tasks.example.com)都注册到同一个授权服务端。服务端 A 被攻陷。攻击者拿用户的 notes token,重放给服务端 B。
服务端 B 的校验器:
kid 拉 JWKS,验签。iss 是否在其受保护资源元数据的 authorization_servers 里。(通过——同一 IdP。)aud == "https://tasks.example.com"。(失败——token 的 aud 是 https://notes.example.com。)WWW-Authenticate: Bearer error="invalid_token", error_description="audience mismatch", resource_metadata="https://tasks.example.com/.well-known/oauth-protected-resource"。受众声明是协议层挡这攻击的唯一防线。为性能跳过它是最常见的生产错误;校验器必须每次请求都跑,而不是只在会话开始跑一次。规范称之为 access-token 权限限制:MCP 服务端 MUST 拒绝任何没在受众里命名它的 token。
命名注意:规范把 confused deputy 这个词留给一个相关但不同的问题——一个 MCP 服务端作为 OAuth 代理对接第三方 API,用静态 client ID,在未取得逐客户端用户同意时转发 token。受众绑定修的是上面的重放;confused-deputy 的修法是逐客户端同意加上绝不把入站 token 透传给上游 API(MCP 服务端
MUST拿自己单独的上游 token)。
一个客户端一生会跟多个授权服务端打交道。恶意 AS 能让客户端把诚实 AS 的授权码,在攻击者的 token 端点兑换掉。受众绑定帮不上——攻击发生在任何 token 存在之前。防御在客户端(RFC 9207):
issuer。iss 参数与记录的 issuer 比对(简单字符串比对,无归一化),才把 code 发往任何地方。authorization_response_iss_parameter_supported 却没带 iss)→ 拒,连 error 字段都不展示。光 PKCE 挡不住 mix-up,因为客户端会把 code_verifier 交给被引向的任何 token 端点。这就是为何规范要把 issuer 跟 PKCE verifier 与 state 一起按请求记录。
kid,还把攻击者可控的 kid 值变成密钥创建 DoS。回退必须是幂等的 refresh_jwks。aud 声明:部分 IdP 默认不写 aud,除非 token 请求带 resource。校验器必须拒绝缺 aud 的 token,不能把缺失当通配。iss 校验导致 mix-up:不校验 RFC 9207 iss 的客户端,可能被引去把诚实 AS 的 code 兑到攻击者的 token 端点。这是客户端侧失败,资源服务端补不了。registration_access_token 让攻击者能改写 redirect URI。静态存哈希;要求客户端每次更新都呈明文;有嫌疑就轮换。iss:接受任意 iss 的校验器,让攻击者自起一个授权服务端、为目标受众注册客户端、签 token。受保护资源元数据的 authorization_servers 列表就是 allow-list,必须强制。| 能力 | 自建 stdlib | Keycloak | Auth0/Okta CIC | Entra ID | 社交 IdP |
|---|---|---|---|---|---|
| 8414/OIDC 元数据 | 手实现 | 是 | 是 | 是 | 不定 |
| CIMD | 手实现 | 出现中 | 出现中 | 出现中 | 否 |
| RFC 7591 DCR | 手实现 | 是 | 是 | 高端层 | 罕见 |
| RFC 8707 资源 | 手实现 | 是(24.x 起) | 是 | 是 | 罕见 |
| S256 PKCE | 手实现 | 是 | 是 | 是 | 是 |
💡 心法:IdP 能力矩阵是部署闸门,不是采购推荐。先核实目标租户支持 S256 与至少一条注册路径(CIMD/预注册/DCR),再决定能否上生产。社交 IdP 只当身份源用,在上层叠一个你自己的、MCP 感知的授权服务端。
本节产出 outputs/skill-mcp-auth.md——给定一份 MCP 服务端配置和一份 IdP 能力集,这个 skill 产出要立起的授权面:受保护资源元数据、要用的注册路径(CIMD、预注册或 DCR 回退)、JWKS 刷新计划、scope 映射,以及 IdP 不支持完整 RFC 配置时要应用的拒绝规则。
code/main.py 用 stdlib Python 与三个角色——AuthorizationServer、ResourceServer、Client——走完整生产流。流程:授权服务端在 /.well-known/oauth-authorization-server 发布 RFC 8414 元数据;客户端调元数据端点检查注册选项(CIMD 的 client_id_metadata_document_supported、DCR 的 registration_endpoint)与 S256 PKCE 支持;走查走 DCR 回退路径,客户端 POST /register(RFC 7591)拿回 client_id(CIMD 客户端则改为呈上自己的 HTTPS client_id URL 并跳过此步);客户端跑带 resource 指示符(RFC 8707)的 PKCE 授权码流(RFC 7636);用 Authorization: Bearer ... 调 MCP 服务端工具;服务端跑 validate,从 JWKS 缓存解析签名密钥;IdP 轮换一把密钥,定时 refresh_jwks 把 JWKS 重拉进缓存;下一次调用在重叠窗口里用刷新后的密钥校验,旧 token 仍有效;一次对另一 MCP 资源的受众重放尝试得 401,带 audience mismatch 与 resource_metadata 指针。
本节 JWT 用 HS256 共享密钥(只为在 stdlib 上跑)。生产用 RS256 或 EdDSA 配上述 JWKS 模式,校验逻辑其余部分完全一致。因 IdP 与资源服务端同进程,refresh_jwks 直接读授权服务端的密钥列表;在网络上它是对 jwks_uri 的 HTTP GET。
追踪流:运行 code/main.py,留意步骤 6 里 IdP 轮换密钥、定时 refresh_jwks 重拉、旧 token(重叠窗口)与新 token 都无需重启地校验通过。
加 IdP:在受保护资源元数据的 authorization_servers 列表里加一个 IdP,签一张它签的 token,确认校验器接受;再签一张未列 IdP 签的 token,确认被拒并回 WWW-Authenticate: Bearer error="invalid_token", error_description="iss not allowed"。
限流注册:给 register_client 加一个按源 IP 的令牌桶(用按 IP 的字典),在注册器接受请求前跑。
补字段校验:读 RFC 7591,找出本节 /register 处理器没校验的两个字段并补上(提示:software_statement 与 redirect_uris 的 URI scheme)。
加 CIMD 路径:服务一个 client.json,其 client_id 等于自身 URL,让授权服务端拉取并校验(client_id ≠ URL 即拒)。确认 CIMD 客户端在不调 register_client 的情况下注册。
证明 DoS 修复:给校验器送一个带随机 kid 的 token,确认 refresh_jwks 至多跑一次、授权服务端的密钥数不增长。然后故意把回退重接到轮换并铸造,看密钥数随每个假 token 攀升——事后改回重拉。
实现 mix-up 防御:实现混搭小节的客户端侧 RFC 9207 iss 校验:授权请求前记录预期 issuer,授权响应的 iss 不匹配即拒。
token.aud)。client_id,授权服务端拉元数据,信任根植于 DNS,无逐服务端状态。registration_access_token 要哈希存。aud:这是协议层挡跨服务端 token 重放的唯一防线,规范称 access-token 权限限制,不能为性能省略。S256 即拒绝部署。iss 校验,授权请求前记录 issuer、响应不匹配即拒。下一节,我们把视野从「Agent 调工具」扩展到「Agent 调 Agent」——A2A 协议:Agent Card、Task 生命周期、对内部推理保持不透明的协作。