Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions content/authors/yang-luo/_index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
title: Yang Luo
role: Casbin 开源社区发起人
bio: "Casbin 开源社区发起人,关注访问控制、身份认证与授权。"
organizations:
- name: Casbin
---
109 changes: 109 additions & 0 deletions content/blog/mcp-server-oauth21-dcr/index.md
Original file line number Diff line number Diff line change
@@ -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 为该社区项目。本文所述的规范要求与选型清单适用于任何实现,与具体项目无关。