feat(auth): accept OpenAI client ID metadata documents - #10
feat(auth): accept OpenAI client ID metadata documents#10binyangzhu000-sudo wants to merge 5 commits into
Conversation
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.
dev 部署状态(2026-08-28)CIMD 已在 dev 跑起来并验证通过,但部署用的不是本分支单独构的镜像,原因记在这里免得下次重踩。 本分支单独构的镜像在 dev 起不来
集群 env 是 所以本 PR 里那次 digest 钉定已 revert( 实际部署的镜像一次性 combo 分支
dev 上验到哪一步pod 内直连
最后两行说明服务器真的抓了 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.
dev 全链路已验通(2026-09-01)接着上一条评论。现在 dev 上除「人在浏览器点 Google」之外的每一段都实测过了,本 PR 又多了两个 commit。 实测 Codex 的真实行为(不再是推断)用一个假的 MCP + 授权服务器(同时公示 CIMD 与 DCR)让真实 Codex CLI 0.152.0 去连: 两点值得记下来:即使 新增 commit 1:
|
| 套件 | 结果 |
|---|---|
| CIMD 探针(discovery / 正例 / 7 条反例 / DCR 兜底 / 令牌端点) | 21/21 |
| 授权码 → 令牌(编程建 Grant+Code,真实 POST /token) | 全通:client_id 是文档 URL、aud 与 scope 按 resource 收窄、grant_id 在、refresh 已轮换 |
| MCP 工具层 | 9/9,其中 atlas_list_models 真的用用户绑定的 key 调到了 Atlas API;atlas_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——那条缺陷在旧镜像上同样存在。
为什么
OpenAI 官方文档(https://developers.openai.com/plugins/build/auth)对 ChatGPT 与 Codex 两个 surface 都优先 CIMD(Client ID Metadata Documents)、DCR 只作兜底,而且明确了触发条件:授权服务器必须公示
client_id_metadata_document_supported: true;registration_endpoint只在支持 DCR 时才给。我们的授权服务器此前只公示
registration_endpoint,所以 Codex 只能走 DCR 注册。Atlas 自己那套新 OIDC(console.dev.atlascloud.ai)也是只提供 CIMD、刻意不公示registration_endpoint,两侧都要求我们把这条路支持起来。改了什么
oidc-provider的clientIdMetadataDocument(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 字符>,一套判据放不下两边。AUTH_CLIENT_ID_METADATA_HOSTS,默认chatgpt.com)的 https 文档——抓取由未认证的授权请求触发,没有白名单等于开了一个任意 URL 的服务端抓取面;公开客户端不得声明认证公钥;private_key_jwt的jwks_uri也必须在白名单主机上;回调必须与application_type匹配;文档缓存上限从默认 24 小时压到 1 小时。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单独开。怎么验的
npm test93/93 通过,其中新增 7 个用例:https://chatgpt.com/oauth/codex/client.json授权,不做任何注册。抓文档 → 建客户端 → 登录 → 同意 → 回调带code与iss;断言了无端口登记匹配带随机端口的实际回调(这条断了 Codex 永远拿不到码)、form-action收紧到实际回调 origin、文档只被抓一次。为此给
AuthorizationAppDependencies加了一个仅测试用的抓取注入点:CIMD 要求client_id是公网 https URL,单测没法真托管一份,但「抓文档→建客户端→匹配回调」正是这条路最容易错的地方,只测纯函数验不到。生产不传,走oidc-provider默认 fetch(自带 SSRF 与体积防护)。client_id_metadata_document_supported: true、registration_endpoint仍在、authorization_response_iss_parameter_supported: true(Codex 只在这个字段为真时才用稳定 client.json,否则按 MCP server URL 派生新客户端,用户的已授权应用列表会堆一片且撤销撤不干净);关闭时该字段消失。private_key_jwt开启后的接受/拒绝各一条。client_id无法借道 CIMD 策略。文档形状不是照文档抄的,是 2026-08-28 实测的:稳定文档回调是
http://127.0.0.1/callback与http://localhost/callback(无端口、无 callback id),派生文档.../codex/<callback_id>/client.json的回调才是/callback/<callback_id>。备注
console.dev.atlascloud.ai当授权服务器是下一步(令牌校验去掉grant_id、加account_id、自检里userinfo_endpoint/emailscope 降级为可选、调模型 API 改走 token exchange),需要身份团队先补 discovery 字段、给atlas-mcp-server-dev的密钥、并处理 dev 网关对/api/v1/oidc/*的拦截。npm run validate:production-config在本分支与 base 上同样失败(缺MCP_OAUTH_JWKS_URI等运行时 env),不是本次引入。