diff --git a/content/authors/yang-luo/_index.md b/content/authors/yang-luo/_index.md new file mode 100644 index 0000000000..a88f47df4f --- /dev/null +++ b/content/authors/yang-luo/_index.md @@ -0,0 +1,7 @@ +--- +title: Yang Luo +role: Casbin 开源社区发起人 +bio: "Casbin 开源社区发起人,关注访问控制、身份认证与授权。" +organizations: +- name: Casbin +--- \ No newline at end of file diff --git a/content/blog/mcp-server-oauth21-dcr/index.md b/content/blog/mcp-server-oauth21-dcr/index.md new file mode 100644 index 0000000000..0dfe7662d9 --- /dev/null +++ b/content/blog/mcp-server-oauth21-dcr/index.md @@ -0,0 +1,109 @@ +--- +title: "远程 MCP Server 的鉴权:为什么动态客户端注册绕不过去" +summary: "当 MCP Server 从本地 stdio 挪到远程,鉴权就成了必答题。本文梳理 MCP 规范为什么选择 OAuth 2.1、它与传统 OAuth 之间的那处错位,以及一套可用的实现需要备齐哪些 RFC。" +authors: ["Yang Luo"] +categories: ["安全"] +tags: ["MCP", "OAuth", "安全", "身份认证", "AI Agent"] +draft: false +date: 2026-09-14T16:30:00+08:00 +--- + +过去一年里,把内部系统包一层 MCP(Model Context Protocol)Server 暴露给 AI Agent,几乎成了标准动作。本地跑的时候一切都好:stdio 起一个子进程,谁能连上谁就有权限,既没有身份也不需要身份。 + +问题是在把它挪到远程的那一刻一次性全来的:谁在调这个工具?他能调哪几个?token 谁发、谁验?用户撤销授权之后,已经发出去的 token 怎么办? + +MCP 规范给的答案是 OAuth 2.1。听上去像个二十年前就解决了的问题,但中间有一处错位,不讲清楚就会在实现时撞墙。这篇想把这个错位、以及一套能用的实现到底要备齐哪些东西,一次说明白。 + +## 一处错位:客户端事先不认识授权服务器 + +传统 OAuth 的整个流程建立在一个隐含前提上:**客户端是事先注册好的**。 + +开发者去某个平台的开发者后台申请一个应用,拿到 `client_id` 和 `client_secret`,填好 `redirect_uri`,把这几个值写进配置文件。等用户真正点"使用 X 登录"的时候,客户端和授权服务器早就互相认识了。 + +Agent 不是这么工作的。 + +用户在 Claude Code、Cursor 或者任意一个 MCP 客户端里填进一个 Server 地址,这个客户端此前从没见过你的授权服务器,也没有任何途径持有一个预先分配的 `client_id`。你不可能让流程停下来说"请先去我们后台注册一个应用,拿到凭据再回来"——这个环节一出现,你的 MCP Server 就没人用了。 + +所以**动态客户端注册(Dynamic Client Registration,RFC 7591)在这条链路上是必需品,而不是锦上添花**。客户端必须能自己发现注册端点、自己完成注册、自己拿到凭据,整个过程对用户无感。 + +这也是很多人第一次实现远程 MCP Server 时最容易漏掉的一环:OAuth 2.1 的授权码流程写对了,PKCE 也加了,结果客户端根本走不到授权那一步,因为它压根没有 `client_id`。 + +## 从客户端视角走一遍完整链路 + +按规范,一次冷启动的接入大致是这样: + +1. 客户端访问 MCP Server 的受保护资源,拿到 401,响应里通过 `WWW-Authenticate` 指向资源服务器元数据。 +2. 客户端读取 **Protected Resource Metadata(RFC 9728)**,从中得知这个资源由哪个授权服务器保护。 +3. 客户端读取授权服务器的 `/.well-known/openid-configuration`(或 OAuth 授权服务器元数据),从中拿到 `authorization_endpoint`、`token_endpoint`,以及关键的 `registration_endpoint`。 +4. 客户端向 `registration_endpoint` **POST 一份自己的描述**完成注册,拿回凭据。 +5. 客户端发起 OAuth 2.1 + PKCE 的授权码流程,用户在同意页上确认。 +6. 客户端拿 token 调 MCP Server,Server 验签、校验受众。 + +第 3、4 步就是动态注册在链路里的位置。注册请求的主体通常长这样: + +```json +{ + "client_name": "Example MCP Client", + "redirect_uris": ["http://127.0.0.1:33418/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "token_endpoint_auth_method": "none" +} +``` + +注意 `token_endpoint_auth_method` 常常是 `none`:桌面端的 Agent 客户端是公开客户端,没法安全保管 secret,所以安全性由 PKCE 承担,而不是 client secret。 + +## 选型清单:一套能用的实现要备齐什么 + +如果你打算自己实现,或者要挑一个现成的身份提供方接上去,下面这几项建议逐条核对。少任何一项,链路都会在某个具体场景下断掉。 + +**1)OAuth 2.1 授权码流程 + PKCE(RFC 7636)。** 这是底座。公开客户端必须强制 PKCE,`plain` 方法不要支持。 + +**2)授权服务器元数据里公布 `registration_endpoint`。** 只实现注册端点但不在 discovery 文档里公布,等于没实现——客户端发现不了它,只能靠人工带外配置,又回到了老路上。 + +**3)RFC 7591 动态客户端注册。** 注册端点要处理好 `redirect_uris` 的校验:Agent 客户端普遍使用 `127.0.0.1` 上的随机端口做回调,这一类 URI 要能接受,同时不能放松到允许任意重定向。 + +**4)RFC 7592 客户端生命周期管理。** 只做 7591 不做 7592 是很常见的实现,代价是注册出去的客户端永远收不回来:改不了、删不掉,也查不到它当初注册的是什么。在一个会被成千上万个 Agent 实例注册的端点上,这是个会随时间累积的问题。做了 7592,注册响应里会带上 `registration_access_token` 和 `registration_client_uri`,后续可以读、改、删。 + +**5)授权同意页。** 动态注册意味着任何人都能注册一个客户端,所以"用户明确知道自己在授权什么"这一步不能省。同意页上应当展示客户端名称、请求的 scope,并且要让用户意识到这个客户端是刚刚自助注册的。 + +**6)JWKS 端点与 JWT 签名校验,以及 resource indicator(RFC 8707)。** resource indicator 这一项容易被忽略:它让 token 绑定到特定的资源服务器。没有它,用户授权给 A 服务的 token 可能被拿去访问 B 服务——在"一个授权服务器背后挂着很多 MCP Server"的部署形态下,这是个实打实的横向越权面。 + +**7)撤销要真的生效。** 这一项不在任何一个 RFC 的标题里,但最容易出事:用户删除了会话,对应的 token 是不是跟着失效?back-channel logout 的通知是在 token 过期之前还是之后发出的?顺序反了的话,RP 收到通知时 token 已经失效,它拿不到上下文,也就无从清理自己那一侧的状态。 + +## 一个工程细节:新注册的客户端不该是一张白纸 + +规范之外,有一条是实际跑起来才会遇到的。 + +动态注册产生的应用,如果在你的身份提供方里是一条"裸记录"——没有品牌化配置、没有启用任何登录方式、一个身份源都没绑——那么用户从 Agent 客户端跳转过来时,看到的会是一个完全陌生、甚至无法完成登录的页面。他不会认为是自己配错了,只会认为你的服务坏了。 + +所以动态注册出来的客户端应当**继承一份默认配置**:登录页的品牌化字段、启用哪些登录方式(密码、验证码、WebAuthn)、绑定哪些身份源。这件事规范不管,但它决定了这条链路在真实用户手里能不能走通。 + +## 鉴权解决"能不能调",审计解决"实际调了什么" + +把身份和权限做完,你知道了谁有资格调用哪些工具。但还有另一半问题:**这个 Agent 实际上调了什么?** + +这一层通常交给 OpenTelemetry。让 Agent 侧的可观测性组件采集 trace、metric 和 log,按 OTLP 推给后端,每一次工具调用、每一次模型调用、每一次 agent 之间的交互都落成结构化记录。放在云原生的语境里,这和我们对待任何一个工作负载的方式没有区别——只不过被观测的对象从服务变成了 Agent。 + +两件事要注意:一是 OTLP 的 ingest 端点通常没有用户态鉴权,裸奔在公网上不是好主意,至少要有一层来源校验;二是遥测数据要能和 agent 的身份对应起来,否则你拿到一堆 span,却说不清是哪个 agent 在什么授权下产生的。 + +于是整条链路是闭合的:**Agent 通过动态注册获得身份 → 按权限调用工具 → 运行时行为以 OTLP 落成可审计的记录。** + +## 落地:自建还是用现成的 + +这套东西自己从头实现一遍不是不行,但它属于"写错了很久都发现不了、发现的时候已经出事了"的那类代码。绝大多数团队应该找一个现成的身份提供方接上去,把上面那七项当成选型清单去核对。 + +接入方式本身很轻:把你自己 MCP Server 的 Protected Resource Metadata 指向选定的授权服务器,用户认证、授权同意、token 签发与校验都在那一侧完成,你只管写你的工具。 + +以笔者参与的开源项目 [Casdoor](https://github.com/casdoor/casdoor) 为例,它在最近的版本里把上述几项补齐了:注册端点在 `.well-known/openid-configuration` 中公布,RFC 7591 的注册与 RFC 7592 的客户端管理都已实现,动态注册产生的应用会继承默认应用的品牌化字段与登录方式配置,另外提供了 OTLP 的 ingest 端点用于承接 Agent 运行时的遥测数据。它同时自身也是一个 MCP Server,把用户、应用、权限三类对象的增删改查暴露为工具。 + +需要说明的是,这类能力并非某一个项目独有,Keycloak 等成熟的身份提供方同样提供动态客户端注册。**选型时真正该做的是拿上面那份清单逐条核对你手上的候选方案,而不是看谁的文档里出现了"支持 MCP"这几个字。** + +## 小结 + +远程 MCP Server 的鉴权不是一个新问题,它是 OAuth 在一个新场景下的重新排列:客户端从"事先注册"变成了"自助注册",于是动态客户端注册从可选项变成了必需品。 + +如果只记一条,建议是这条:**在评估任何一个方案时,先去它的 `/.well-known/openid-configuration` 里看有没有 `registration_endpoint`。** 没有的话,后面的功能列表再长,这条链路在冷启动那一步就断了。 + +--- + +**利益相关**:笔者是 Casbin 开源社区发起人,文中提及的 Casdoor 为该社区项目。本文所述的规范要求与选型清单适用于任何实现,与具体项目无关。