Skip to content

feat(auth): accept OpenAI client ID metadata documents - #10

Open
binyangzhu000-sudo wants to merge 5 commits into
feat/credential-auto-linkfrom
feat/client-id-metadata-documents
Open

feat(auth): accept OpenAI client ID metadata documents#10
binyangzhu000-sudo wants to merge 5 commits into
feat/credential-auto-linkfrom
feat/client-id-metadata-documents

Conversation

@binyangzhu000-sudo

Copy link
Copy Markdown
Contributor

为什么

OpenAI 官方文档(https://developers.openai.com/plugins/build/auth)对 ChatGPT 与 Codex 两个 surface 都优先 CIMD(Client ID Metadata Documents)、DCR 只作兜底,而且明确了触发条件:授权服务器必须公示 client_id_metadata_document_supported: trueregistration_endpoint 只在支持 DCR 时才给。

我们的授权服务器此前只公示 registration_endpoint,所以 Codex 只能走 DCR 注册。Atlas 自己那套新 OIDC(console.dev.atlascloud.ai)也是只提供 CIMD、刻意不公示 registration_endpoint,两侧都要求我们把这条路支持起来。

改了什么

  • 打开 oidc-providerclientIdMetadataDocument(9.11.3 原生带,draft-02,ack 钉在版本上:以后升级草案版本会直接启动报错,强制复核而不是静默按新语义跑),由 AUTH_CLIENT_ID_METADATA_ENABLED 控制。DCR 保留作兜底,策略不变。
  • 两套客户端策略并存,按 client_id 的形态分派:CIMD 的 client_id 就是 https 文档地址,DCR 的 client_id 由本服务生成、并覆盖请求提交的同名字段,所以分派判据不会被外部污染(有用例覆盖)。
    必须分开的原因:CIMD 文档登记的 loopback 回调不带端口(Codex 每次监听随机端口,RFC 8252 匹配忽略端口),DCR 提交上来的带端口 + /callback/<12 字符>,一套判据放不下两边。
  • CIMD 抓取与准入策略:只抓白名单主机(AUTH_CLIENT_ID_METADATA_HOSTS,默认 chatgpt.com)的 https 文档——抓取由未认证的授权请求触发,没有白名单等于开了一个任意 URL 的服务端抓取面;公开客户端不得声明认证公钥;private_key_jwtjwks_uri 也必须在白名单主机上;回调必须与 application_type 匹配;文档缓存上限从默认 24 小时压到 1 小时。
  • 修掉一个会让 CIMD 100% 死在交互页的问题supportedCallback()redirect_uri 收紧登录/同意页的 form-action,原先只认 DCR 形状,任何 CIMD 形状都会被判 authorization redirect URI is not supported。现在两套形状都认(新增 classifyCallback),CIMD 关闭时退回只认 DCR 那套。
  • private_key_jwt 默认不公示:ChatGPT 的顶层文档声明的是 private_key_jwt,一旦令牌端点公示它,ChatGPT 就会从已经验过的 DCR 路径切到这条没验过的路径上。接 ChatGPT 的 CIMD surface 时再用 AUTH_CLIENT_ID_METADATA_ALLOW_PRIVATE_KEY_JWT=true 单独开。
  • 部署:dev/staging overlay 显式打开 CIMD(要在 dev 验 Codex 真机连接),生产 overlay 显式关闭,等 dev 验完、且确认 ChatGPT 在 CIMD 被拒后仍会退回 DCR 再开。

怎么验的

npm test 93/93 通过,其中新增 7 个用例:

  • 端到端:Codex 用稳定文档 https://chatgpt.com/oauth/codex/client.json 授权,不做任何注册。抓文档 → 建客户端 → 登录 → 同意 → 回调带 codeiss;断言了无端口登记匹配带随机端口的实际回调(这条断了 Codex 永远拿不到码)、form-action 收紧到实际回调 origin、文档只被抓一次。
    为此给 AuthorizationAppDependencies 加了一个仅测试用的抓取注入点:CIMD 要求 client_id 是公网 https URL,单测没法真托管一份,但「抓文档→建客户端→匹配回调」正是这条路最容易错的地方,只测纯函数验不到。生产不传,走 oidc-provider 默认 fetch(自带 SSRF 与体积防护)。
  • discovery 公示矩阵:开启时有 client_id_metadata_document_supported: trueregistration_endpoint 仍在、authorization_response_iss_parameter_supported: true(Codex 只在这个字段为真时才用稳定 client.json,否则按 MCP server URL 派生新客户端,用户的已授权应用列表会堆一片且撤销撤不干净);关闭时该字段消失。
  • 策略拒绝矩阵 14 条(异主机文档、http 文档、异主机回调、application_type 与回调不匹配、带 query/fragment 的回调、共享密钥认证、关闭时的 private_key_jwt、内联 jwks、公开客户端带 jwks_uri、未知 scope 等),外加 private_key_jwt 开启后的接受/拒绝各一条。
  • DCR 请求自带 client_id 无法借道 CIMD 策略。

文档形状不是照文档抄的,是 2026-08-28 实测的:稳定文档回调是 http://127.0.0.1/callbackhttp://localhost/callback(无端口、无 callback id),派生文档 .../codex/<callback_id>/client.json 的回调才是 /callback/<callback_id>

备注

  • 这条 PR 只动插件自己的授权服务器。切到 Atlas 的 console.dev.atlascloud.ai 当授权服务器是下一步(令牌校验去掉 grant_id、加 account_id、自检里 userinfo_endpoint / email scope 降级为可选、调模型 API 改走 token exchange),需要身份团队先补 discovery 字段、给 atlas-mcp-server-dev 的密钥、并处理 dev 网关对 /api/v1/oidc/* 的拦截。
  • npm run validate:production-config 在本分支与 base 上同样失败(缺 MCP_OAUTH_JWKS_URI 等运行时 env),不是本次引入。

OpenAI prefers Client ID Metadata Documents over dynamic client registration
for both the ChatGPT and the Codex surface, and only walks that path when the
authorization server advertises client_id_metadata_document_supported. This
server advertised registration_endpoint alone, so Codex had to register
dynamically.

Enable oidc-provider's clientIdMetadataDocument feature behind
AUTH_CLIENT_ID_METADATA_ENABLED (on by default, off in the production overlay
until Codex is verified on dev) and give it its own policy: documents are only
fetched from an allowlisted host, a public document must not carry client
authentication keys, and callbacks must match the declared application type.
DCR stays enabled as the fallback and keeps its stricter policy, because the
two client shapes differ — a metadata document registers the loopback callback
without a port, since Codex listens on a random one and RFC 8252 matching
ignores it.

The two policies are dispatched on the shape of client_id: a metadata document
client_id is the https document URL, while a dynamically registered client_id
is generated here and overwrites whatever the request supplied, so the
dispatch cannot be steered from outside.

Interaction pages now classify both callback shapes. They derive the
form-action origin from redirect_uri and previously rejected anything that was
not a DCR-shaped callback, which would have failed every CIMD login at the
consent page.

private_key_jwt stays unadvertised by default: ChatGPT's top-level document
declares it, and advertising it would move ChatGPT off the DCR path it is
verified on.
Built on controlplane001 from 8ea9ce7 as tag 20260828-cimd, linux/amd64.
That image was built from this branch alone and cannot start on dev: the
cluster sets AUTH_REFRESH_TOKEN_TTL_SECONDS=7776000, while this branch caps it
at 604800 — the commit that raised the cap lives on the PR #3 branch. dev runs
a combo image (#3 + #4 + #8 + this branch), so its digest does not belong to
any single feature branch.
@binyangzhu000-sudo

Copy link
Copy Markdown
Contributor Author

dev 部署状态(2026-08-28)

CIMD 已在 dev 跑起来并验证通过,但部署用的不是本分支单独构的镜像,原因记在这里免得下次重踩。

本分支单独构的镜像在 dev 起不来

20260828-cimd / sha256:b8f29bc4…(本分支 8ea9ce7,linux/amd64)部署后 crashloop,exit 1

Atlas Cloud OAuth server failed: [ { "code": "too_big", "maximum": 604800,
  "path": ["AUTH_REFRESH_TOKEN_TTL_SECONDS"] } ]

集群 env 是 AUTH_REFRESH_TOKEN_TTL_SECONDS=7776000(90 天),而本分支的 base(#4)把上限卡在 604800;把上限提到 7776000 的 1f72a61 decouple Codex refresh tokens from sessions#3 的分支上。dev 现网跑的 4c1bd80c(tag 20260819-v2fix)本来就是 #3+#4+#8 的合成镜像。

所以本 PR 里那次 digest 钉定已 revert(db95439)——一个在 dev 起不来的镜像不该写进 overlay。

实际部署的镜像

一次性 combo 分支 combo/cimd-20260828combo/autolink-plus-sessionfix + 本分支的 CIMD commit,冲突只有 test fixture 的 store 那一处,npm test 99/99):

  • tag 20260828-cimdcombo,digest sha256:097c52977909f8cd79ccb68082489fe5b6e8333770c2a537f08ea58581713b4c,linux/amd64,在 controlplane001 原生构建
  • 只换了 atlascloud-openai-auth 的 auth 容器(本 PR 只动 src/auth/**),atlascloud-openai-mcp 保持 977b4b7d…
  • 三个 CIMD env 用 kubectl set env 注入:AUTH_CLIENT_ID_METADATA_ENABLED=true / HOSTS=chatgpt.com / ALLOW_PRIVATE_KEY_JWT=false
  • 回滚点:/root/mcp-plugin-cimd-backup-20260828/{auth,mcp}-before.yaml(controlplane001)

dev 上验到哪一步

pod 内直连 :3001 实测:

结果
client_id_metadata_document_supported true
registration_endpoint 仍公示(DCR 兜底未动)
authorization_response_iss_parameter_supported true
token_endpoint_auth_methods_supported ["none"]
GET /auth?client_id=https://chatgpt.com/oauth/codex/client.json&… 303 → /interaction/…
pod 内 fetch("https://chatgpt.com/oauth/codex/client.json") 200(集群能出网抓文档)

最后两行说明服务器真的抓了 Codex 的文档、建了客户端、并接受了「文档登记的无端口 loopback 回调 ↔ 实际带随机端口的回调」这层 RFC 8252 匹配,然后进入登录交互。

还没验的:登录/同意之后真正拿到 code,以及 Codex CLI 真机连接——这两步要真人在浏览器里操作。

…es against

The MCP container fetches JWKS from MCP_OAUTH_JWKS_URI and validates the
authorization server metadata on startup, both against
atlascloud-auth.dev.atlascloud.ai. Cluster DNS cannot resolve that name — the
dev ingress hostnames only exist in the corporate resolver, there is no public
A record — so JWKS fetches failed with ENOTFOUND. Token verification is
fail-closed, which turned every authenticated MCP request into a 401 even for
a token whose every claim was correct, and the startup check would have
crashlooped the pod on its next restart.

Point both ingress hostnames at the ingress gateway so the traffic loops back
through the ingress that already serves them. The gateway certificate covers
both names, so this holds up under strict TLS verification.

This is independent of the CIMD change; it rides along because the CIMD work is
what surfaced it.
…enge

MCP clients locate the protected resource metadata through the
WWW-Authenticate header on an unauthenticated request, and Codex probes
GET /mcp first. That endpoint is stateless and POST-only, so it answered 405
with no challenge and left the client to guess the .well-known path. Codex does
guess — measured against a stand-in server, it tries
/.well-known/oauth-protected-resource/mcp and gets there — but that costs two
extra round trips per connect and not every client guesses.

Send the challenge when no credentials were presented at all, and keep 405 for
requests that did present some.
@binyangzhu000-sudo

Copy link
Copy Markdown
Contributor Author

dev 全链路已验通(2026-09-01)

接着上一条评论。现在 dev 上除「人在浏览器点 Google」之外的每一段都实测过了,本 PR 又多了两个 commit。

实测 Codex 的真实行为(不再是推断)

用一个假的 MCP + 授权服务器(同时公示 CIMD 与 DCR)让真实 Codex CLI 0.152.0 去连:

client_id    = https://chatgpt.com/oauth/codex/client.json   ← 稳定文档,走 CIMD
redirect_uri = http://127.0.0.1:60024/callback               ← 无 callback_id + 随机端口
code_challenge_method = S256 ,  resource = <MCP URL>
POST /register                                               ← 从未调用

两点值得记下来:即使 registration_endpoint 摆在那里,Codex 也优先 CIMD、完全不碰 DCR;回调形状正是本 PR 放行的那种,不做这个改动 Codex 根本连不上(旧 DCR 策略要求 /callback/<12 字符> 且带端口)。另外 Codex 有 scope 降级重试(被拒 scope 后去掉 openid 重试一次),我们对 CIMD 客户端不限制 scope 所以第一次就成。

新增 commit 1:1d6d61a — mcp pod 解析不了它要校验的 ingress 域名

拿一枚每项 claim 都正确的真实令牌打 MCP 会 401。根因是 MCP_OAUTH_JWKS_URI 指向 atlascloud-auth.dev.atlascloud.ai,而 pod 内 getaddrinfo ENOTFOUND——两个 dev ingress 域名公网无 A 记录、只在公司内网解析器里,集群 DNS 也没有。令牌校验 fail-closed,于是每个认证请求都变 401。

这不是 CIMD 引入的(出问题的 pod 当时已跑 12 天,本 PR 只碰 auth 容器),而且它还是个定时炸弹:mcp 启动时先跑授权服务器元数据自检、拉的正是同一个域名,所以那个 pod 一旦重启就会 crashloop。

修法是给 mcp Deployment 加 hostAliases 指向 ingress 网关 ClusterIP,让流量绕回本来就服务这两个域名的 ingress。网关证书 SAN 覆盖它们,rejectUnauthorized=true 严格校验下也 200。

新增 commit 2:c774876 — 未认证的 GET/DELETE 给发现挑战

Codex 用 GET /mcp 触发发现,而这个端点只接受 POST,原先回 405 且不带 WWW-Authenticate。实测 Codex 能自己猜出 /.well-known/oauth-protected-resource/mcp(我们恰好注册了这条)所以不阻塞,但多两轮往返、也不是每个客户端都会猜。现在:无凭据 → 401 + 挑战,带凭据 → 仍 405。

测试

本地 npm test 93/93。dev 上 30 项:

套件 结果
CIMD 探针(discovery / 正例 / 7 条反例 / DCR 兜底 / 令牌端点) 21/21
授权码 → 令牌(编程建 Grant+Code,真实 POST /token) 全通:client_id 是文档 URL、audscope 按 resource 收窄、grant_id 在、refresh 已轮换
MCP 工具层 9/9,其中 atlas_list_models 真的用用户绑定的 key 调到了 Atlas APIatlas_get_balance / atlas_generate_image 因缺 scope 被 403 + insufficient_scope 在执行前拦掉

顺带纠正一处我自己写错的预期:宽限窗口内重放旧 refresh_token 返回 200 是设计(窗口 30s / 上限 2 次内隐藏 consumed 标记,给并发重试留口子),不是缺陷。

dev 现状

两个容器 digest 已统一到 @sha256:e79d35bd…(tag 20260901-cimdcombo2,来自 combo 分支 b177869)。改动前 digest:auth 4c1bd80c… / mcp 977b4b7d…。回滚镜像时不要一起回滚 hostAliases——那条缺陷在旧镜像上同样存在。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant