跳到正文
FoxAuth

博客

MCP 服务器必须检查令牌是给谁的

通俗讲解 MCP 授权:MCP 服务器必须发布什么,令牌里哪个字段必须指明它,以及规范留给你的两个问题。

作者 FoxAuth ·

这个令牌是为内部 wiki 签发的,却打开了 MCP 服务器上的所有工具。

这台 MCP 服务器的做法和大多数初次尝试一样。它下载了身份提供商的公钥;身份提供商就是签发令牌的那个服务。它校验了令牌的签名和过期时间。它从令牌里读出 subject,也就是令牌所指的那个用户,然后认定请求已通过认证。每一项检查都通过了,因为令牌是真的。对 wiki 来说,它是一个完全合格的令牌。但 MCP 服务器从来没有问过:这个令牌是给谁的?所以,任何人只要持有这个提供商签发的任意令牌,不管它是为什么签发的,都能列出这些工具并调用它们,身份就是令牌里写的那个人。

MCP 授权规范要防止的正是这个漏洞。看清这个漏洞之后,规范的其余部分就不再像一套新协议了。MCP 服务器就是一个普通的 OAuth 2.1 资源服务器:它接收令牌,并据此让调用方使用自己的东西(这里就是它的工具)。规范额外做的,只是把发现(discovery)步骤写了下来:一个只知道服务器 URL 的客户端,如何一步步拿到令牌。我在另一侧花了不少时间,也就是编写授权服务器,即签发令牌的服务器。我的总结是:MCP 授权就是三份文档加一个声明(声明就是令牌里的一个字段,claim)。几乎所有真正的问题都出在这一个声明上。

从一个 URL 到一个令牌:三份文档和一个声明

智能体宿主是 AI 智能体运行所在的程序;用 OAuth 的话说,它就是客户端。它只知道一件事:你的 MCP 服务器的 URL。从这个 URL 出发,它要弄清三件事:哪个授权服务器在保护你,怎样和那个服务器通信,以及怎样向它表明自己的身份。然后,它要带着一个只给你、不给任何别人的令牌回来。前三个问题由文档回答。最后这一点,靠的是令牌里的一个声明。

智能体宿主如何为 MCP 服务器拿到令牌:一个指向服务器元数据的 401、这份元数据、授权服务器的元数据、在 resource 参数中指明 MCP 服务器的授权请求和令牌请求,以及 MCP 服务器会检查是否签发给自己的令牌智能体宿主(客户端)MCP 服务器授权服务器1 POST /mcp,不带令牌2 401 WWW-Authenticate: Bearerresource_metadata=“…”, scope=“mcp:tools-basic”3 GET 受保护资源元数据(RFC 9728)resource, authorization_servers, scopes_supported4 GET 授权服务器元数据(RFC 8414 或 OIDC 发现)issuer, endpoints, code_challenge_methods_supported, client_id_metadata_document_supported5 /authorize client_id=host.example/client.json resource=mcp.example.com/mcpPKCE;服务器获取 client.json 以取得名称和重定向目标;用户授权同意6 /token code + code_verifier + 同一个 resource7 aud = mcp.example.com/mcp 的访问令牌8 POST /mcp Authorization: Bearer …9 校验签名、iss、exp 和 aud 后返回 200

第 2 步是普通资源服务器本来不会做的。规范把它定为 MUST。它所指向的元数据文档(第 3 步)和受众检查(第 9 步)也同样是 MUST。受众就是令牌里的 aud 声明:它写明令牌是签发给哪台服务器的。第 2 步本身只有两行。如果请求没有带令牌,或者带的令牌被你拒绝,你就返回 401,并说明你的元数据在哪里。这种响应叫作质询(challenge):服务器在 WWW-Authenticate 头里告诉客户端怎样获得访问权限:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
scope="mcp:tools-basic"

这份元数据是 RFC 9728 定义的一份 JSON 文档,叫作受保护资源元数据(protected resource metadata)。MCP 服务器在里面说明,是哪个授权服务器在保护它:

{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://auth.example.com/acme"],
"scopes_supported": ["mcp:tools-basic"],
"bearer_methods_supported": ["header"]
}

从这里开始,就是授权服务器和客户端的事了。客户端从文档中取出签发者(issuer)。签发者是授权服务器的名字,写成一个 URL;这个服务器签发的每个令牌都在 iss 中带着这个 URL。客户端用这个 URL 去获取授权服务器自己的元数据。获取它的标准(well-known)地址有两个:一个由 RFC 8414 定义,一个由 OpenID Connect Discovery 定义。客户端必须两个都试,因为规范只要求服务器发布其中一个。然后,客户端执行一次普通的带 PKCE 的授权码流程:它通过浏览器拿到一个授权码,再用它换取令牌;PKCE 让截获授权码的人无法使用它。在授权请求和令牌请求中,客户端都会再发送一个参数。这个参数从 2020 年起就有了 RFC,但在实践中至今一点也不普通。

resource 参数让令牌只对你的服务器有效

这个参数就是 RFC 8707(Resource Indicators)中的 resource。客户端用它说明,自己要的是给哪台服务器用的令牌。MCP 规范要求两个请求都必须带上它。正是它,把「来自该提供商的一个令牌」变成了「给这台 MCP 服务器的令牌」。授权服务器把这个 URI 作为受众(aud)写进令牌。MCP 服务器则拒绝任何受众不完全等于其自身标识符的令牌。wiki 令牌本来会在这一关被拦下,而别的任何检查都拦不住它。

下面是授权服务器在上图流程中返回的令牌,已解码:

{
"iss": "https://auth.example.com/acme",
"aud": "https://mcp.example.com/mcp",
"sub": "248289761001",
"client_id": "https://host.example/client.json",
"scope": "mcp:tools-basic",
"iat": 1759226400,
"exp": 1759227300,
"jti": "o1S6Qw3bH9c_UZL0"
}

在 MCP 服务器一侧,对这个令牌的校验归结为四项检查。其中两项必须由你自己指定,因为 JWT 库猜不出它们:

import { createRemoteJWKSet, jwtVerify } from 'jose';
const keys = createRemoteJWKSet(new URL('https://auth.example.com/acme/jwks'));
export async function principalFor(authorization) {
const token = authorization?.replace(/^Bearer /i, '');
if (!token) return null;
const { payload } = await jwtVerify(token, keys, {
issuer: 'https://auth.example.com/acme',
audience: 'https://mcp.example.com/mcp'
});
return { subject: payload.sub, scopes: (payload.scope ?? '').split(' ') };
}

签名和过期时间(exp)由库自己检查。issuer 和 audience 要由你提供,而第一次尝试漏掉的正是 audience。任何语言的任何库都做同样的四项检查。上面这段代码,实际上就是检查清单。

我花了更久才体会到的是:同一个字符串现在出现在三个地方。写下它的是三个不同的参与方,而它在三处必须完全相同。MCP 服务器在元数据中把它发布为 resource。授权服务器的运营方登记它,也就是把它注册为一个受保护资源的标识符。客户端把它作为 resource 发送,令牌则把它作为 aud 携带。比较是精确的,并且在规范化之后进行:两边的 URL 都先被改写成同一种标准写法(下文细说)。精确比较是有意为之。不做前缀匹配,也没有「父路径的令牌对子路径同样有效」。因为一个集成的令牌恰恰就是这样在另一个集成那里生效的。

同一个标识符出现在由三方写下的三个地方:MCP 服务器元数据中的 resource 字段、运营方在授权服务器上的登记,以及客户端发送、随后成为令牌 aud 的 resource 参数。签发者也以同样方式把元数据的 authorization_servers 与令牌的 iss 联系起来MCP 服务器发布resource: mcp.example.com/mcpauthorization_servers: [acme 签发者]scopes_supported: [mcp:tools-basic]运营方登记标识符:mcp.example.com/mcp位于项目 acme,其签发者为 /acmescope:[mcp:tools-basic]客户端发送resource=mcp.example.com/mcp发往 /acme/authorize 和 /acme/tokenscope 取自质询令牌aud: mcp.example.com/mcp iss: acme 签发者scope:所请求的 ∩ 所登记的同一个字符串,在每一处都经规范化后精确比较

规范化正是我们第一个版本出 bug 的地方。我们是通过推敲代码发现它的,测试没有发现。规范说,客户端应当写不带末尾斜杠的 URL,服务器应当接受大写的 scheme 或主机名。所以,我们让登记和传入的请求都经过同一个函数做规范化。这个函数有一个选项,用于末尾斜杠有意义的资源。bug 是这样的:登记在存储时保留了斜杠,但比较时,我们用默认选项规范化了请求。于是斜杠只从一侧被去掉了。结果,一个声明斜杠有意义的资源,塌缩成了它不带斜杠的孪生兄弟:两个不同的资源被当成了一个。两侧必须经过同一个函数、使用同样的选项。如果这听起来显而易见——对我来说也一样,只不过是在事后。

关于受众,还有一点。每个令牌都只绑定到恰好一个已登记的资源,也就是管理员注册为受保护资源的某个 API 或 MCP 服务器。正因为如此,登记资源可以安全地作为数据而不是代码存在。在我们的服务器中,登记一台 MCP 服务器就是在控制台里填一张表:一个标识符、一个在授权同意页面上显示的名称,以及这台服务器识别的 scope。scope 是令牌可以携带的一项有名字的权限,例如 mcp:tools-basic。不需要重启任何东西。每次令牌请求都会读取登记,前面没有缓存。原因是:一条登记一旦被删除,从下一个请求起就必须停止为它签发令牌,而不是等某个缓存副本过期之后。这个教训我们已经学过一次:当时一个客户端缓存把过时的客户端交给了管理控制台。没必要再学一次。

智能体用一份文档的 URL 表明身份:方便,也有风险

智能体宿主手里只有你的 MCP 服务器的 URL,别的什么都没有。你没有地方把客户端标识符(client_id)粘贴进去。规范最初给出的答案是动态客户端注册。客户端 POST 一份对自己的描述,拿回一个 client_id。授权服务器则为每一个曾经连接过的宿主保存一条记录。当前修订版的规范把这种做法标为已弃用,转而把 Client ID Metadata Documents(客户端元数据文档)放在首位。我认为这是正确的决定。同时,这也是规范中最值得为之紧张的部分。

思路是这样的:client_id 本身就是一个 HTTPS URL。这个 URL 上放着一份描述客户端的 JSON 文档,也就是它的元数据文档:

{
"client_id": "https://host.example/client.json",
"client_name": "Example Agent Host",
"redirect_uris": ["http://127.0.0.1:52123/callback", "https://host.example/oauth/callback"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"]
}

授权服务器在需要时获取这份文档,校验它,什么也不存储。没有注册端点,也没有每多一台笔记本电脑就多一行的客户端表。授权同意页面上显示的客户端名称来自这份文档。文档由客户端的运营方控制,放在一个可以证明归运营方所有的域名上。

代价是:一个陌生人,没有账户也没有任何凭据,现在就能让你的授权服务器向他选定的任意 URL 发出 HTTP 请求。针对这一点的所有防护都必须放在服务器上。我们的做法是一个只处理网络、别的什么都不管的模块。它拒绝私有地址、环回地址和链路本地地址。环回地址是本机自己的地址,例如 127.0.0.1;链路本地地址只在本地网段内有效。这个模块把地址当作数值而不是文本来比较。原因是,URL 解析器会毫不犹豫地把 IPv4 映射的 IPv6 地址(以 IPv6 形式书写的 IPv4 地址)改写成文本检查发现不了的形式。模块手动跟随重定向,并对每一跳重新检查。它读到 5 KB 就停止读取,而且有时间上限。这项功能默认关闭,原因正是本段第一句关于陌生人的那句话。

拿到文档之后,检查就很简短,每一项都封堵一种具体的伎俩。文档里的 client_id 必须与文档自己的 URL 逐字节相等,所以托管一份写着别人标识符的文档毫无用处。redirect_uris(登录后浏览器可以被送回的地址)必须存在且非空。对称的客户端密钥(客户端和服务器共享的一个密码)会被拒绝,因为放在人人可读的文档里的密钥就不再是密钥。如果客户端用密钥对认证,文档必须说明这些密钥在哪里。

我认为真正尚未解决的是环回重定向。原生智能体宿主是运行在用户自己电脑上的应用。它在 127.0.0.1 的某个端口上等待登录后的回调,所以它的文档列出一个环回重定向地址。文档能证明写它的人控制着那个域名,却无法证明本机上是哪个程序在监听那个端口。同一台机器上的攻击者可以出示真实宿主的 client_id,并在自己的端口上监听。用户在授权同意页面上看到的,却是真实宿主的名称。规范知道这一点,说服务器应当发出警告,然后就止步于此。对于普通资源,我们像所有人一样承担这一风险。

但有一个地方,这类客户端可以拿到管理权限:本服务器自己的管理 MCP 端点,也就是智能体用来管理本服务器的那个端点。在那里,我们把警告变成了硬性拦截。如果一个客户端的文档只提供环回重定向,那么在管理员接受一份书面说明之前,这个客户端不会获得许可;说明里写明了这样的文档无法证明什么。

多个团队共用一台服务器时,智能体让谁的用户登录

这是一个规范没有提出的问题。它假设一个授权服务器为一群用户保护一台 MCP 服务器。而一旦有不止一个团队在使用授权服务器,情况就不是这样了。

假设服务器承载多个租户。租户就是一个与别人共用服务器、但有自己用户的团队。这时来了一个由元数据文档标识身份的智能体宿主。它不属于任何租户,它只是一个 URL。它让谁的用户登录?我们最终确定的答案是:由资源决定。某个租户的管理员在自己的一个项目中登记了那台 MCP 服务器。所以,指明该资源的请求就让该项目的用户登录。值得精确说明一下为什么这是安全的。把这个资源和这个项目关联起来的不是客户端,而是管理员,在他自己拥有的项目里。所以 resource 参数只能在运营方已有的登记之中做选择,无法凭空创建一条新登记。

更准确地说,它只对没有控制台账户的攻击者是安全的。有账户的人可以登记任何东西。标识符在整个实例范围内唯一,谁先登记就归谁。于是,一个恶意租户可以登记别人的 MCP 服务器。真正的所有者此后每次尝试登记都会遇到冲突,永远如此。而那台 MCP 服务器的登录,会被导入恶意租户的用户桶。用户桶是本服务器对一组最终用户的称呼,它有自己的登录规则。一旦用户桶有了自己的地址,它也就成了一个独立的签发者。

第一个修复要求每条登记自证。服务器获取该资源自己的元数据,检查其中是否指明了本签发者。这是很自然的修复。但它也拒绝了所有授权服务器获取不到其元数据的 MCP 服务器:位于内网的、开发期间跑在 localhost 上的、尚未部署的。它只撑了一天。

同一授权服务器上的两个租户,各自是独立的签发者,都登记了同一个 MCP 服务器标识符。到达某个租户签发者的请求只看得到该租户的登记。在共享的根签发者上,只有超级管理员可以登记签发者 auth.example.com/acmeacme 的用户、acme 的密钥登记 mcp.example.com/mcp任一 acme 管理员,无需获取或证明签发者 auth.example.com/globexglobex 的用户、globex 的密钥同样登记 mcp.example.com/mcp只对自己的命名空间有效根签发者 auth.example.com由没有独立地址的租户共享登记:仅限超级管理员元数据只能指明签发者,无法指明租户/acme/authorize?resource=…/mcp解析到 acme 的登记acme 的用户;globex 的登记不可见/globex/authorize?resource=…/mcp解析到 globex 的登记同一个标识符,不同的令牌/authorize?resource=…/mcp未在根上登记则返回 invalid_target

取而代之的是命名空间,这也是我现在会推荐给任何构建这类系统的人的方案。这里的命名空间,指属于同一个签发者的全部登记。拥有独立地址的租户本身就是一个签发者,有自己的签名密钥。资源标识符只需在一个签发者内唯一。两个租户可以登记同一台 MCP 服务器,彼此永远看不到对方的登记。这是因为每次查找只读取请求所到达的那个地址的命名空间。在你自己的命名空间里做登记不需要证明,所以服务器什么也不获取:这样的登记只可能涉及你自己的用户。

真正共享的命名空间只有一个:根签发者。所有没有独立地址的租户都在它下面提供服务。根签发者只接受超级管理员的登记。原因是:在根上,资源的元数据可以说它信任这个签发者,却无法说明它属于其中哪个租户。因此在根上,任何证明都挡不住抢注,也就是某个租户抢先登记别人的标识符。所以只有超级管理员可以在根上登记。

同一处还剩下三个漏洞,都围绕已登记的资源。前两个形式相同:「调用方忘了传入资源」。两者都值得了解,因为这种设计恰恰会催生这类错误。

第一个:账户解析器(在用户登录时查找该用户的代码)去找用户的租户,与登录步骤用的不是同一个。登录步骤传入了资源,解析器却没有。症状是授权同意页面上出现 500 错误,而不是一次拒绝。

第二个:密码登录、密码重置和注册只根据客户端来选择策略。设想一个租户,它的用户只能通过上游提供商(也就是外部身份提供商)登录。借助一个已登记的资源,别人仍然可以用密码进入这个租户。

我们把资源变成必填参数,堵住了这两个漏洞。现在,漏传资源的代码无法通过类型检查。如果你只从这一节带走一条工程规则,就带走这一条:当一个参数选择的是租户时,要让人无法在不传它的情况下调用该函数。

最后一个漏洞是机器令牌。为一个人申请令牌时,这个人会给出授权同意。所以任何客户端,哪怕是不属于任何人的客户端,都可以请一个人为已登记的资源授予令牌。客户端凭据(client credentials)令牌则不同:客户端是为自己拿令牌,谁也不用问。在我们注意到之前,下面这件事是行得通的。任何租户的任何机密客户端(有自己密钥的客户端),或者一个自行注册的客户端,都可以向受害租户的令牌端点请求令牌。而且真的能拿到:受众是受害者的资源,签发者是受害者的签发者。现在,已登记资源的机器令牌只发给登记该资源的那个项目的客户端。规范里对此只字未提。它也不会提:只有多个租户共用一台服务器时,这个问题才会出现。

你列出的每个 scope 都可能授予每个智能体,所以列表要短

有一件小事让我意外。假设你的 401 响应中的质询没有带 scope。这时规范会让客户端请求你 scopes_supported 中的所有 scope,并说明这是有意为之:通用客户端无法在它不理解的名字之间做出合理选择。所以,元数据里的一长串列表并不是一份可供挑选的菜单,而是今后每个连接进来的智能体都会被授予的东西。

所以,把你的工具真正需要的 scope 放进质询。让 scopes_supported 只保留基本使用所需的最小集合。当某个特权工具第一次被调用时,再用 403 响应和 insufficient_scope 错误请求更多权限。客户端必须以质询中的 scope 为准。它还要自己记住在逐步提权(step-up,每次多要一点权限)过程中累积的 scope。正因如此,授权服务器不需要记住这些。

我们自己的管理 MCP 端点只列出 openid,别的什么都不列。它的权限来自管理员所属的组,而不是来自 scope。一个端点从不检查的 scope,没有必要公布。

规范留给你的问题:令牌格式和混淆代理人

第一个问题是令牌格式,规范刻意没有做出决定。MCP 服务器必须拒绝不是发给它的令牌,「或以其他方式验证自己是预期的接收方」。这种措辞容许两种令牌。自包含令牌就像上面的例子,自身携带所有数据,用已发布的密钥校验。不透明令牌是一串没有内容的随机字符串,要通过内省来检查,也就是去问授权服务器这个令牌是否仍然有效。我们默认使用有效期十五分钟的自包含令牌。原因是:内省要求你的 MCP 服务器持有自己的凭据,而十分钟的上手指南里容不下这一步。代价是,自包含令牌在过期之前一直有效。如果被撤销的令牌必须在几秒而不是几分钟内失效,就把资源登记为不透明格式,给你的 MCP 服务器一个自己的客户端,并为每次调用付出一次内省请求的代价。两种答案都没有错。错的是不知道自己选了哪一个。

第二个问题是混淆代理人(confused deputy)问题:受托持有他人权限的中间方,被人利用来反过来对付权限的主人。解决它不是授权服务器的事。设想一台代理第三方 API 的 MCP 服务器。它用一个固定的 client id 访问那个 API。它动态注册自己的 MCP 客户端。它还在不展示自己授权同意页面的情况下转发授权码。安全指南逐个攻击剖析的,恰恰就是这种配置。防护分两部分。第一,代理自己按客户端征求授权同意,而且要在第三方 API 的授权开始之前。第二,代理检查受众,确保它收到的令牌绝不会被原样转发出去。我描述过的规范的每一部分都对此有帮助,但没有哪一部分能替你完成这项工作。

我还不知道的是,元数据文档的信任策略在实践中会如何定下来,也就是授权服务器应当接受哪些客户端 URL。规范允许三种做法:接受任意 HTTPS client_id、只接受列表中的域名,或者做信誉检查。每一种都打开了一条不同的钓鱼途径,因为授权同意页面上的名称就是文档里写的那个名称。默认开放是让生态得以运转的前提,允许列表则是企业会想要的。我怀疑答案是两者兼有,按租户选择。这又是规范会继续留给服务器运营者的一个问题。

如果你想端到端地看清全貌,用 OAuth 保护 MCP 服务器 大约需要十分钟。它会带你走完资源登记、你的服务器发布的两样东西(元数据和 401 质询)、令牌检查,以及两种结果:一次拒绝和一次成功。决定智能体宿主如何表明身份的有两个设置:clientIdMetadataDocument.enabled 用于元数据文档,registration.enabled 用于动态注册。端点参考列出了每个发现 URL 返回什么。