【免费下载链接】treg
OpenRouter for agent tools. Join community here: https://discord.gg/6mQYYfFMAn
本篇技术指南以 treg 仓库中的 MCP-OAUTH-PLAN.md 为主线,完整讲解 treg 如何从「OAuth 客户端」转身成为「OAuth 授权服务器(authorization server)」:通过 OAuth 2.1 授权码 + PKCE 流程,让任意符合 MCP 授权规范的客户端(ChatGPT、Claude Code、Cursor 等)以「打开链接、选择团队、完成授权」的方式接入 treg 的 MCP 服务,替代手工复制 Bearer Token。读完本文,你将掌握该方案的设计决策、两份元数据文档、客户端注册双通道(DCR 与 CIMD)、授权码与刷新令牌轮换的完整实现原理,以及 aud 声明为何是整套方案最关键的防线,并可对照源码与测试用例逐项验证。
现状:已上线,且被真实客户端验证过
文档开篇即声明本方案 Status: SHIPPED,已在生产环境运行。它按六个步骤逐段构建、每步先评审再推进,最终验收测试通过:MCP SDK 自带的 OAuth 客户端驱动了整条流程,且无需任何 treg 专用特殊分支;ChatGPT 通过 CIMD 连接后成功发起了一次计费调用并按团队正确扣费。完整的设计说明沉淀在参考文档 docs/context/architecture/mcp-oauth.md。
定位澄清:这是 MCP 授权规范的能力,不是 ChatGPT 专属功能
MCP 授权规范(MCP authorization spec)并非 ChatGPT 私有协议。任何实现了该规范的客户端——Claude Code、Claude Desktop、Cursor,以及未来任何新客户端——都使用相同的两份元数据文档、相同的 PKCE 流程、相同的同意页。ChatGPT 只是第一个消费者,不是设计目标。这样定位还顺带解决了 Codex 路径今天存在的问题:环境变量里的 Bearer Token 需要用户手工复制,而 OAuth 将其替换为「打开链接、选择团队、完成」。
由此得出两个「现在做很便宜、回头改很别扭」的决策:
- 不硬编码重定向 URI。ChatGPT 的
https://chatgpt.com/connector/oauth/{callback_id}只是一个已注册客户端的回调地址,必须按客户端逐一校验,而不是写死在代码里的常量。 - 同时支持动态客户端注册(DCR)与 CIMD。ChatGPT 偏好 CIMD(客户端 ID 元数据文档),而 Claude Code 及大多数其他客户端使用 DCR(RFC 7591 动态注册)。如果只支持 CIMD,就会悄悄把其他客户端全部挡在门外——而且因为唯一测试客户端恰好不需要 DCR,这种缺口很难被发现。
为什么这一环不可省略
ChatGPT 的 connector 表单只提供三种认证选项:OAuth、No Auth、Mixed,根本没有 Bearer Token 选项。Codex 有(codex mcp add --bearer-token-env-var),所以 Codex 半边今天能工作,ChatGPT 半边不能。
关键认知:「No Auth」不是阉割版产品,而是完全不同的产品。文档给出在无令牌生产环境的实测结果:
| 工具 | No Auth 下的行为 |
|---|---|
catalog_search、catalog_get | 可用——目录本身就是公开数据 |
call、balance、my_tools | 返回 {"error": "not authenticated"} |
原因在于 treg 是多租户系统:每一次调用都必须回答「谁的余额来付、能用谁的密钥、适用谁的配额、审计行写进谁的账」。令牌正是回答这些问题的载体。没有令牌就没有调用者身份——无论对评审者还是安装插件的用户都是如此。若改为把令牌塞进 URL,等于把同一个团队的余额和私钥交给目录里的每个用户,这正是 treg 存在的意义要防止的事情。
方向转变:从 OAuth 客户端到授权服务器
treg 此前已经会说 OAuth,但只作为 客户端:oauth.py 构造 PKCE challenge 用于 GitHub / Google 登录以及连接 provider 账户。而这里 treg 必须扮演 授权服务器——签发令牌的一方,而不是兑换令牌的一方。这个角色此前并不存在。
有两块资产可以直接复用:
session.py已能签发带声明(claims)和过期时间的 HMAC 签名令牌(make/read_claims)。访问令牌只是携带更多声明的同构产物,因此签名机制已经写就并经评审。- GitHub、Google、邮件验证码三扇登录门已经在浏览器中识别真人。authorize 端点复用这个会话,而不是另造一套登录。
从源码看,令牌类型用 typ: "treg-mcp-at" 做类型标记,防止会话 Cookie 与访问令牌互相冒充(详见 domain/identity/mcp_oauth.py)。
规格要求:每个合规客户端都会实现的公共部分
以下要求来自 MCP 授权规范,是所有合规客户端都会实现的内容。ChatGPT 专属的只有它的重定向 URI 和对 CIMD 的偏好——其余全部与所有客户端通用:
| 要求 | 细节 |
|---|---|
| 流程 | OAuth 2.1 授权码 + PKCE S256 |
| 受保护资源元数据 | MCP 主机上的 GET /.well-known/oauth-protected-resource |
| 授权服务器元数据 | GET /.well-known/oauth-authorization-server |
| 客户端身份 | CIMD——ChatGPT 以 HTTPS 元数据 URL 作为 client_id,需对外声明 client_id_metadata_document_supported: true;同时支持 DCR,服务使用 DCR 的客户端 |
| 重定向 URI | ChatGPT 的为 https://chatgpt.com/connector/oauth/{callback_id}——按客户端存储,绝不假设 |
resource 参数 | ChatGPT 会在 authorize 与 token 请求上追加 resource=<我们的 mcp url>;必须原样拷贝进令牌的 aud 声明 |
| 客户端认证 | none(公开客户端 + PKCE)或 private_key_jwt |
元数据实现的细节可在 domain/identity/mcp_oauth.py 中核对:protected_resource_metadata 声明 resource、authorization_servers、scopes_supported 与 bearer_methods_supported: ["header"];authorization_server_metadata 只声明 response_types_supported: ["code"]、grant_types_supported: ["authorization_code", "refresh_token"]、code_challenge_methods_supported: ["S256"]、token_endpoint_auth_methods_supported: ["none"],并显式开启 CIMD 支持。测试 test_mcp_oauth.py 断言:只提供 code 响应类型、不提供 plain PKCE、同时声明了 CIMD 与注册端点——任何可被客户端钻空子的降级选项都被刻意排除。
作用域方面,V1 基础作用域为 ["treg:catalog", "treg:call", "treg:read"];V2 目录面额外追加 treg:directory(见 mcp_oauth.py)。这一 V2 专属标记在托管 Claude 省略 RFC 8707 resource 参数时用于选择目录受众。
落地实现:五个组成部分
0. 客户端注册——两条通道,一张表
- DCR(RFC 7591):
POST /oauth/register,客户端发送名称与重定向 URI,服务端存储并返回client_id(格式为treg-client-+ 高熵随机串,见 mcp_oauth.py)。注册本身不授予任何权限——每个令牌仍需人类在同意页批准。 - CIMD:客户端以 HTTPS 元数据 URL 作为
client_id,服务端抓取并缓存。
无论从哪条通道进来,都落到同一张 OAuthClient 表,因此后续的 authorize、token、consent 只读取一种形状,不关心客户端如何到达。CIMD 文档会被缓存并定期刷新(24 小时),文档可能变化,永不失效的缓存会把客户端钉死在早已退役的重定向 URI 上(见 application/auth.py 的 _resolve_oauth_client)。
注册时重定向 URI 有硬性校验:必须为 HTTPS,或回环地址的 HTTP(127.0.0.1、::1、localhost)——CLI 客户端无法持有证书,回环是 RFC 8252 认可的特例(见 mcp_oauth.py)。测试 test_mcp_oauth.py 验证了 http://evil.example.com/cb、ftp://、带 fragment 的 URL 都会被 400 拒绝。
1. 两份元数据文档
静态 JSON,无状态:
/.well-known/oauth-protected-resource:命名规范资源(https://treg.superdesign.dev/mcp/),指向 treg 自身作为授权服务器,列出支持的作用域。/.well-known/oauth-authorization-server:公布 authorize 与 token 端点、code_challenge_methods_supported: ["S256"]与 CIMD 支持。
元数据同时在资源自身路径下提供服务(/.well-known/oauth-protected-resource/mcp 与 V2 的 /mcp/v2 变体),因为规范允许客户端从主机根路径或资源路径两处查询,具体选哪一处不是服务端能决定的(测试见 test_mcp_oauth.py)。
2. GET /oauth/authorize——同意页即安全边界
复用浏览器会话。若访客未登录,先通过既有登录门走一遍再回来。然后展示一页内容:这个 connector 可以为哪个团队行事、能做什么。
团队选择器不是装饰,它回答的是一个真实问题。一个人可以属于多个团队,而 balance 之前不得不在没有标记 active 团队时拒绝并询问。同意页正是这个选择真正该发生的地方——由人一次性决定,而不是每次调用时猜测。
同意页同时展示每个团队的余额(balance_usd)。选择一个余额为 $0.00 的团队正是这个页面要防止的失败——真实发生过:首次真实 ChatGPT 连接时用户选了一个空余额团队,调用被拒,而页面上没有任何信息能提前告知。
签发一个短生命周期授权码,绑定:用户、所选 org、code_challenge、redirect_uri 与 resource。
实施细节值得注意(见 application/auth.py):
- 先校验客户端与重定向 URI,之后才允许任何错误重定向——因为在此之前没有安全的错误回送地址。
- 只接受
response_type=code与 PKCES256;省略 challenge 或提供plain一律拒绝(降级选项不能让任何人随手可用)。 - 授权码 TTL 为 300 秒(
AUTH_CODE_TTL_S)。 - 批准必须用 POST:GET 只要能被任何页面导航触发,就会在用户不知情的情况下授权。测试
test_a_GET_never_grants_anything断言 GET 只渲染问题、绝不重定向回带 code 的地址(test_mcp_oauth.py)。 - 跨域提交被 403 拒绝;页面继承全局
X-Frame-Options: DENY防点击劫持。Origin: null仅在Sec-Fetch-Site佐证为同源时接受——浏览器在经登录跳转的重定向链后确实会报告不透明 origin,把它一律当跨站处理会让批准间歇性失败(test_mcp_oauth.py)。 - 用户选择团队时服务端会再次校验其成员资格——
org_id来自浏览器,属于客户端可篡改字段。
3. POST /oauth/token——兑换与刷新
验证 PKCE verifier 与存储的 challenge(S256)、重定向 URI 是否匹配、授权码是否未用且未过期。授权码单次使用——兑换时先删除再校验(await db.delete(row) 在验证之前执行),重放将一无所获。这避免了「持码校验」留下的双重兑换窗口,与账本 reserve 中条件更新(conditional UPDATE)让数据库仲裁的推理一致(见 application/auth.py)。
响应包含:携带 sub(用户)、org(所选团队)、aud(resource 原样值)与过期时间的访问令牌,外加一个刷新令牌。刷新至关重要:连接器一小时即死且无法恢复,等于每个用户每天一个工单。响应头设置 Cache-Control: no-store 与 Pragma: no-cache(测试断言,见 test_mcp_oauth.py)。
访问令牌 TTL 为 3600 秒(ACCESS_TTL_SECONDS),刷新令牌 TTL 为 30 天(REFRESH_TTL_S,见 mcp_oauth.py)。刷新令牌在数据库中只存哈希——数据库副本就是数据库泄露(测试 test_refresh_tokens_are_stored_HASHED 断言库中无明文且哈希为 64 位十六进制,test_mcp_oauth.py)。
刷新令牌轮换:每次使用即作废旧令牌并签发新令牌,且被作废的行保留不删除。保留让重放可识别——已删除的令牌看起来只是「未知」,而退役令牌表明「有人使用了一枚已被消费的凭证」。此时客户端在丢包后重试与窃贼重放副本无法区分,安全起见整条 family_id 全部吊销;判错一次代价是一次重新登录,而判错另一方向代价是某人的余额(application/auth.py)。刷新令牌还绑定客户端(client_id 不匹配即拒绝)且不能在刷新时换团队——团队选择是同意时的决定,刷新只是续期,不是第二次选择机会。
3b. 面向用户的可打印链接
同意页是普通 URL,因此没有浏览器集成的客户端也能用:打印链接 → 人类打开 → 批准 → 粘贴回短代码,与今天 treg login 的形状一致。这让 Claude Code 及任何终端客户端无需我们的特殊支持即可工作。
4. mcp.py 中的令牌接受
_bearer() 已读取 Authorization 头(mcp.py)。它新增一个分支:若令牌是 OAuth 访问令牌,则验证签名、检查 aud 是否匹配我们自己的 MCP URL——拒绝为其他资源签发的令牌正是这个声明的全部意义——并将 sub+org 解析为成员资格。现有 per-org 与身份令牌保持原样工作,Codex 不受影响。
实现细节(mcp.py):
_oauth_claims先按 V1 再按 V2 的受众集合验证;返回 None 不是拒绝,而是「这不是 OAuth 令牌」,调用方回退到 per-org / 身份令牌路径。- 真正的拒绝是静默的:
aud指向其他资源的令牌会被判无效——用户把授权给了别人的 MCP 服务器,在这里兑现就会花掉他们从未给过我们的授权额度。 _internal_auth做令牌交换而非转发:OAuth 访问令牌经验证后,转为一个 120 秒短生命周期、aud=identity的桥接身份令牌,钉死在同意页选定的团队上。这让 OAuth 逻辑留在mcp.py内部,而不是给require_member引入第三种令牌类型;同时_resolve_org必须尊重被钉死的团队而不是重新推导。该缺陷正是真实运行流程而非单元测试发现的:_oauth_claims验证完全正确,但工具仍把原始 bearer 转发给内部 API,于是全部回复「not signed in」(mcp.py)。- 对于原生身份令牌,
_internal_auth通过read_identity_claims读出签名中的团队钉扎并以X-Treg-Org头透出——多团队用户把 dashboard 的「API key」粘贴进 MCP 客户端时,不会再撞上「请选择团队」的 400。
MCP 传输层还实现了急切认证:RequireAuthForProtectedTools 对任何未携带凭证的 MCP 请求直接回 401 并带 WWW-Authenticate: Bearer scope="…" resource_metadata="…" 挑战头;对声称是 OAuth 访问令牌但已过期/签名错/受众错的 bearer,则回 401 + error="invalid_token"(RFC 6750 §3.1)——这个错误码正是 OAuth 客户端触发刷新授权(refresh grant)的信号,没有它,过期令牌会以 200 内的友好文案滑过,Claude Code 会放弃并报「requires re-authorization」而不是静默刷新。只有无 id 的通知与 ping 免凭证通过;.well-known/* 发现路由是独立 GET,不受此中间件影响(见 mcp-oauth.md)。
5. 存储
新增一张授权码表(短命、单次使用)与一张刷新令牌表(可吊销)。访问令牌保持无状态,与会话 Cookie 同构:HMAC 签名、自描述、热路径上无需查库。
可能出问题的地方与排查优先级
文档逐条列出了风险清单,每条都能在源码与测试中找到对应防线:
aud误处理——若接受了受众为其他资源的令牌,为别人服务器签发的令牌就能在我们这里生效。这是测试套件中最重要的一条断言。实现上read_access_token把expected_audience设为必填参数且无默认值,测试断言缺参调用会抛TypeError而不是默认放宽(test_mcp_oauth.py)。同时 authorize 端点在签发前就拒绝我们不服务的resource(invalid_target),因为签一枚格式正确却静默无用的令牌,会让失败拖到首次工具调用时才以「not signed in」呈现,把人引向认证问题而真正原因在受众(application/auth.py)。每个 MCP 版本有各自的受众集合,V1 与 V2 的令牌绝不互通;主机别名与尾斜杠变体可在同一版本内归一化(normalize_resource),但绝不跨版本(见 mcp-oauth.md)。- CIMD 抓取——
client_id是服务端要抓取的 URL,抓取攻击者提供的 URL 是请求伪造风险:必须 HTTPS、必须限时(5 秒超时)、不得跟随重定向到私网地址、结果必须缓存。实现复用health.safe_webhook_url与health.host_is_public,而不是另写一份 SSRF 防护副本;64KB 大小上限;文档还必须声明自己的 URL,否则托管在任意位置、声称他人client_id的文档就能继承其用户已给予的同意(mcp_oauth.py,测试见 test_mcp_oauth.py)。 - 同意页是安全边界——它是人类唯一能看到自己在授予什么的地方。必须直白地写明团队与能力,且不可被 iframe 嵌入。页面文案用的是人话(「spends the team's balance」「without seeing them」——密钥被使用但绝不展示),而不是罗列作用域;对 DCR 自注册客户端还显示警告「registered itself / only continue if you recognise it」,因为动态注册开放,任何人都能带着任意名字出现(测试见 test_mcp_oauth.py)。
- 刷新令牌轮换——用后即转、检测重放;永不过期的泄露刷新令牌比短命访问令牌更糟。
- 作用域不要渗入代理——OAuth 决定谁在调用,绝不能变成第二个决定能调用什么的地方;后者留在
_resolve_marketplace_call,否则两份副本会漂移。
实施顺序与验收节点
- 元数据文档 +
mcp.py中的aud检查。此时还不签发任何令牌——这是「拒绝错误令牌」的部分。 - 客户端注册——DCR 与 CIMD 进入同一张表。
- 带 PKCE 的
authorize+token、数据库中的授权码、无 UI——由测试驱动。 - 同意页,包括团队选择器。
- 刷新令牌与轮换。
- 停下,连接真实客户端。刻意连两个:ChatGPT(CIMD)与 Claude Code(DCR)。如果第二个客户端需要我们改代码,说明设计有误,此刻发现代价最低。
- 然后是插件清单、演示录制与提交材料的其余部分。
诚实的规模评估
这是数天而非数小时的工作——流程本身成熟,但这是一片全新的安全攻击面,测试套件必须「令人信服」而非仅仅「通过」。Codex 路径全程保持可用且不需要其中任何部分,因此整个过程中始终存在可交付的产品。
已上线后的增强:授权后的团队可见与可逆
架构文档补充了上线后迭代的关键约束(见 mcp-oauth.md):
balance与my_tools标注授权归属:显示team_name与identity(账号通常才是差异所在);若授权指向的团队不在该身份的/orgs列表中,答案会直说。- 团队可在不重新同意的前提下迁移:可迁移状态存放在刷新家族的
OAuthGrant权威行(current_org_id),而不是只存在于已签发的访问令牌内,因此GET /oauth/grants+POST /oauth/grants/{family}/team(CLI 为treg mcp grants、treg mcp use-team,见 cli.py)是一次行更新,下次刷新即生效,最迟在访问令牌的一小时生命周期内。迁移双向受限:只有授权归属用户本人可迁移,且只能迁到自己所属的团队。 - 生命周期规则:
OAuthGrant.current_org_id是唯一可变的家族答案,OAuthRefresh.org_id签发后永不变(退役令牌重放按原团队审计);家族在成员关系结束、团队删除等场景下随授权一并消亡;滚动部署中旧实例可能签发缺少权威行的刷新令牌,_ensure_grant会从最老的刷新令牌重建该行。
进一步阅读:仓库内的源码与测试地图
- 规划文档:docs/MCP-OAUTH-PLAN.md(本文主线)
- 架构参考:docs/context/architecture/mcp-oauth.md
- 令牌、元数据、PKCE、客户端注册核心实现:src/treg/domain/identity/mcp_oauth.py
- 流程编排与事务边界(注册、授权、兑换、刷新轮换/重放响应、吊销、团队迁移):src/treg/application/auth.py
- MCP 传输层的令牌接受与桥接(
_oauth_claims、_internal_auth):src/treg/mcp.py - 会话令牌签名机制复用:src/treg/domain/identity/session.py
- CLI 的授权管理命令(
treg mcp grants、treg mcp use-team):src/treg/cli.py - 覆盖元数据、拒绝路径、全流程端到端、重放检测的测试套件:tests/test_mcp_oauth.py
- 演示页面
/connect-demo(模拟第三方应用完整跑一遍注册→同意→兑换→工具调用,由TREG_CONNECT_DEMO_ENABLED环境变量控制、默认关闭):src/treg/web/connect-demo.html
【免费下载链接】treg
OpenRouter for agent tools. Join community here: https://discord.gg/6mQYYfFMAn
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



