ARTICLE · 1103296
Codex 源码-Login/Auth 登录与凭证管理
Codex 源码解析系列
第 6 讲:Login/Auth 登录与凭证管理
基于 OpenAI Codex 源码 · 2026-09-30
💡 本讲一句话:Codex 的登录是一条跑在 localhost 上的完整 OAuth 授权码流程——浏览器去 auth.openai.com 换 token,但凭证的读写全在 codex-login crate 里:8 种凭证统一成一个枚举、4 种存储后端自动降级、到期前主动刷新 + 永久失败分类。读完这一讲,你知道自己的 token 存在哪、为什么不会泄露、过期了怎么续。
一、codex-login:一个 crate 管完所有凭证
第 5 讲我们看过 ModelProvider::auth() 返回的是 CodexAuth——那只是消费方视角。这一讲钻进生产方:codex-login crate(约 8K 行),它负责登录、存储、刷新、吊销四件事。文件地图:
🔹 auth/manager.rs(3090 行):CodexAuth 枚举 + AuthManager + 刷新逻辑,全 crate 的核心🔹 auth/storage.rs(548 行):auth.json 结构 + File/Keyring/Auto/Ephemeral 四种存储后端🔹 server.rs(1318 行):本地 OAuth 回调服务器 + token 交换🔹 pkce.rs / token_data.rs / auth/revoke.rs:PKCE、JWT claims 解析、登出吊销
先看最顶层的类型——所有登录方式最后都收敛到这一个枚举:
📄 codex-rs/login/src/auth/manager.rs (第 79-89 行)
/// Authentication mechanism used by the current user. // 文档注释:当前用户使用的认证机制——这个枚举就是全 Codex 的凭证总入口pub enum CodexAuth { // 8 种登录方式共用一个类型,上层调用方永远只面对它,不需要知道具体是哪种 ApiKey(ApiKeyAuth), // 裸 API key(OPENAI_API_KEY / config)——最简单形态,静态、无需刷新 Chatgpt(ChatgptAuth), // 浏览器 OAuth 交互登录:持有存储后端 + HTTP client,能自己刷新 token ChatgptAuthTokens(ChatgptAuthTokens), // 外部注入的 ChatGPT token(app-server 场景):只有状态、不拥有存储 Headers(AuthHeaders), // 外部系统直接给的自定义请求头——永不落盘,只活在内存里 AgentIdentity(AgentIdentityAuth), // Agent 身份:JWT 或 runtime_id+私钥对,托管 agent 场景专用(第 7 讲细拆) PersonalAccessToken(PersonalAccessTokenAuth), // PAT 个人访问令牌:长效凭证,不走 OAuth 刷新流程 BedrockApiKey(BedrockApiKeyAuth), // Amazon Bedrock API key——静态密钥 BedrockAccessKeys(BedrockAccessKeysAuth), // AWS AK/SK:可以现场签 STS token,是少数"能自己续命"的静态凭证之一} // 枚举结束:新增登录方式 = 加一个变体 + 在 from_auth_dot_json / auth_mode 里补分支为什么这样设计:把"凭证是什么"和"凭证怎么来"解耦。注意 Chatgpt(拥有存储、可刷新)和 ChatgptAuthTokens(纯状态快照)是两个变体——前者是 CLI 自己登录的结果,后者是 app-server 这类宿主进程把 token 喂进来的结果。两者对 API 调用方完全等价(auth_mode() 都归一成 AuthMode::Chatgpt),但生命周期管理完全不同:一个要刷新、一个只能等宿主重新注入。这种"同语义、不同所有权"的拆分,是后面所有刷新逻辑能写干净的前提。
另外 PartialEq 的实现(第 157-167 行)很讲究:Headers/PAT/Bedrock 这几种做全字段比较,其余的只比 api_auth_mode()——因为 ChatGPT token 每次刷新后值都不同,用"模式相等"判断"凭证有没有换账号"才不会被无意义的 token 差异骗到。
二、落盘形态:auth.json 与 TokenData
$CODEX_HOME/auth.json 是凭证的"磁盘真相"。它的结构故意做成一个宽表——所有登录方式的字段平铺在一个 JSON 里,用 auth_mode 标记当前生效的是哪种:
📄 codex-rs/login/src/auth/storage.rs (第 39-65 行)
/// Expected structure for $CODEX_HOME/auth.json. // 文档注释:这就是 auth.json 的期望结构——一个文件装下所有登录方式#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]pub struct AuthDotJson { // 凭证磁盘形态:宽表设计,字段全平铺、靠 Option + auth_mode 区分当前模式 #[serde(default, skip_serializing_if = "Option::is_none")] // 缺省为 None;None 时不写进 JSON——文件里只留有效字段 pub auth_mode: Option<AuthMode>, // 显式记录当前登录方式;老文件没有这个字段时由 resolved_mode() 从有值的字段推断 #[serde(rename = "OPENAI_API_KEY")] pub openai_api_key: Option<String>, // API key——rename 保留旧字段名,兼容用户手里已有的 auth.json #[serde(default, skip_serializing_if = "Option::is_none")] pub tokens: Option<TokenData>, // ChatGPT OAuth 三件套(id/access/refresh)+ 从 id_token 解析出的扁平 claims #[serde(default, skip_serializing_if = "Option::is_none")] pub last_refresh: Option<DateTime<Utc>>, // 上次刷新时间——access token 没有 exp 时的主动刷新兜底依据(8 天阈值) #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_identity: Option<AgentIdentityStorage>, // Agent 身份材料:裸 JWT 或 runtime_id+私钥记录(untagged enum,两种形态都认) #[serde(default, skip_serializing_if = "Option::is_none")] pub personal_access_token: Option<String>, // PAT——长效令牌,直接存字符串 #[serde(default, skip_serializing_if = "Option::is_none")] pub bedrock_api_key: Option<BedrockApiKeyAuth>, // Bedrock API key 凭证 #[serde(default, skip_serializing_if = "Option::is_none")] pub bedrock_access_keys: Option<BedrockAccessKeysAuth>, // AWS AK/SK——注意:私钥类材料也走同一套存储后端,靠 0o600/keyring 保护} // 结构结束:新增登录方式 = 加一个 Option 字段 + resolved_mode() 里补推断规则为什么这样设计:宽表 + Option 是"向前兼容优先"的取舍。serde 反序列化时未知字段直接忽略、缺失字段取默认值——所以老版本写的 auth.json 在新版本上照样能读,新版本加的字段在老文件里就是 None。rename = "OPENAI_API_KEY" 更是把兼容性写进了 schema:用户手工编辑过的文件不会因为字段改名而失效。代价是"一个文件可能同时残留多种凭证的旧值"——所以 auth_mode 字段和 resolved_mode() 推断逻辑才重要:它决定了读出来时到底信哪个。
tokens 里的 TokenData 更值得看——它把 id_token(一个 JWT)"拍平"成了结构化字段:
📄 codex-rs/login/src/token_data.rs (第 10-42 行)
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)]pub struct TokenData { // OAuth 三件套 + 解析后的账号信息:刷新时整体替换,读取时直接用扁平字段 /// Flat info parsed from the JWT in auth.json. // 文档注释:id_token 拍平后的有用 claims——避免每次用都重新解 JWT #[serde(deserialize_with = "deserialize_id_token", serialize_with = "serialize_id_token")] pub id_token: IdTokenInfo, // 反序列化时自动把原始 JWT 解析成 IdTokenInfo;序列化时写回 raw_jwt——磁盘上存的仍是字符串 /// This is a JWT. pub access_token: String, // 访问令牌:每次 API 请求都带它,有效期短(分钟级) pub refresh_token: String, // 刷新令牌:长效"根凭证",用来换新的 access token——泄露危害最大 pub account_id: Option<String>, // ChatGPT workspace/组织 id——多账号切换、workspace 限制校验都靠它}/// Flat subset of useful claims in id_token from auth.json. // 文档注释:id_token 里有用的 claims 子集#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]pub struct IdTokenInfo { // JWT payload → 结构化字段:plan/user_id/fedramp 这些判断不再依赖运行时解析 pub email: Option<String>, // 账号邮箱——UI 展示用 pub chatgpt_plan_type: Option<PlanType>, // 订阅档位(free/plus/pro/business...)——决定功能开关与路由 pub chatgpt_user_id: Option<String>, // ChatGPT 用户 id——Agent Identity 绑定、遥测归因都用它 pub chatgpt_account_id: Option<String>, // workspace/组织 id——企业账号的隔离边界 pub chatgpt_account_is_fedramp: bool, // 该 workspace 是否必须走 FedRAMP 合规边缘——影响请求路由 pub raw_jwt: String, // 原始 JWT 全文保留——需要其他 claims 时不用重新登录,直接再解析一次} // 拍平结束:常用字段 O(1) 读取,生僻字段回退到 raw_jwt为什么这样设计:"解析一次、存成扁平结构"是典型的读多写少优化——plan type、fedramp 标志在会话启动、路由选择、功能门控里反复被问,如果每次都 base64 解码 + JSON 反序列化 JWT payload 就太浪费了。而 raw_jwt 保留原文又留了后路:后端将来加新 claim,老代码不炸(serde 忽略未知字段),需要时从 raw_jwt 再解。deserialize_id_token/serialize_id_token 这对自定义 serde 函数让"磁盘上是字符串、内存里是结构体"对调用方完全透明。
三、存储后端:一个 trait,四种落盘策略
凭证往哪存由 AuthCredentialsStoreMode 决定。所有后端实现同一个三方法 trait(load/save/delete,storage.rs 第 167-171 行),先看最朴素的 File 后端的写路径——这里藏着两个安全细节:
📄 codex-rs/login/src/auth/storage.rs (第 206-223 行)
fn save(&self, auth_dot_json: &AuthDotJson) -> std::io::Result<()> { // 把凭证写进 $CODEX_HOME/auth.json——全文件最敏感的操作 let auth_file = get_auth_file(&self.codex_home); // 固定路径:codex_home.join("auth.json"),位置可预测、便于用户管理 if let Some(parent) = auth_file.parent() { // 首次登录时 codex_home 可能还不存在 std::fs::create_dir_all(parent)?; // 递归建目录;已存在不报错——幂等 } let json_data = serde_json::to_string_pretty(auth_dot_json)?; // pretty JSON:出问题时人能直接打开文件排查(安全靠权限位,不靠不可读) let mut options = OpenOptions::new(); // 组装打开标志 options.truncate(true).write(true).create(true); // truncate+write+create:覆盖写语义——旧凭证内容绝不残留半个字节 #[cfg(unix)] { options.mode(0o600); // Unix 专属:仅属主可读写——共享机器上防其他用户读 token 的核心防线(Windows 靠 ACL) } let mut file = options.open(auth_file)?; // 按上述标志打开/创建文件 file.write_all(json_data.as_bytes())?; // 全量写入;write_all 保证要么写完整、要么报错,不留半截 JSON file.flush()?; // 刷到 OS——尽力持久化(没 fsync:凭证刷新频率低,崩溃丢一次可重新登录) Ok(()) // 成功返回;调用方接着删旧 fallback 文件等收尾} // save 结束为什么这样设计:0o600 是"最小权限"原则在文件层的落地——token 文件不需要 group/other 任何权限。truncate(true) 而不是 append,保证切换账号后旧 token 不会以"文件尾部残留"的形式留下。注意它没有fsync:这是有意的取舍——凭证写入是低频操作(登录、刷新),进程崩溃最多丢一次刷新结果,用户重新登录即可;为这点可靠性付出每次写盘的 fsync 成本不划算。
Keyring 后端多一个工程问题:keyring 条目需要一个"键名"。Codex 的做法是把 codex_home 路径哈希成短 key:
📄 codex-rs/login/src/auth/storage.rs (第 237-249 行)
// turns codex_home path into a stable, short key string // 源码注释:把 codex_home 路径变成稳定、短的 keyring 键fn compute_store_key(codex_home: &Path) -> std::io::Result<String> { // 同一个目录无论怎么拼写,必须映射到同一个 key——否则换个工作目录就"丢登录态" let canonical = codex_home.canonicalize().unwrap_or_else(|_| codex_home.to_path_buf()); // 解析符号链接/相对路径;canonicalize 失败(目录不存在)就用原值兜底 let path_str = canonical.to_string_lossy(); // Path → String,非 UTF-8 字符做有损替换——键名只需要稳定、不需要可读 let mut hasher = Sha256::new(); // SHA-256:完整 hex 有 64 位,对 keyring 条目名来说太长 hasher.update(path_str.as_bytes()); // 喂入规范化路径字节 let digest = hasher.finalize(); // 得到 32 字节摘要 let hex = format!("{digest:x}"); // 转小写 hex(64 字符) let truncated = hex.get(..16).unwrap_or(&hex); // 截前 16 位 hex——碰撞概率可忽略,长度对 keyring UI 友好 Ok(format!("cli|{truncated}")) // cli| 前缀:和 Codex 其他 keyring 条目(如 secrets)区分开} // compute_store_key 结束为什么这样设计:keyring 的键是"用户可见的字符串",直接拿绝对路径当键又长又脆(/home/x/.codex vs ~/.codex vs 符号链接三种拼法)。canonicalize + SHA-256 截断把这三者收敛成同一个稳定短串。cli| 前缀则是命名空间隔离——同一台机器上 Codex 的 secrets、auth 各占一格,互不覆盖。
真正体现产品思维的是 Auto 模式——keyring 优先、文件兜底的双写降级:
📄 codex-rs/login/src/auth/storage.rs (第 431-457 行)
impl AuthStorageBackend for AutoAuthStorage { // auto 模式:keyring 优先、文件兜底——默认体验,兼顾安全与可用性 fn load(&self) -> std::io::Result<Option<AuthDotJson>> { // 读凭证:按"更安全的位置先找"的顺序 match self.keyring_storage.load() { // 首选系统 keyring(macOS Keychain / Linux Secret Service)——OS 级加密 + 访问审计 Ok(Some(auth)) => Ok(Some(auth)), // keyring 命中——直接返回,不碰文件 Ok(None) => self.file_storage.load(), // keyring 里没有(首次登录/迁移期)→ 回退读 auth.json Err(err) => { // keyring 不可用:无桌面环境、Secret Service 没起、CI 容器…… warn!("failed to load CLI auth from keyring, falling back to file storage: {err}"); // 必须打日志——静默降级会让"为什么我的 token 在文件里"变成玄学 self.file_storage.load() // 降级读文件:登录流程不中断,可用性优先 } } } fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> { // 写凭证:同样 keyring 优先 match self.keyring_storage.save(auth) { // 先尝试写 keyring(成功后端内部会顺手删掉旧文件 fallback) Ok(()) => Ok(()), // 写入 keyring 成功——收工 Err(err) => { // keyring 写失败(无桌面环境等) warn!("failed to save auth to keyring, falling back to file storage: {err}"); // 记录降级原因 self.file_storage.save(auth) // 落到 0o600 的 auth.json——安全等级降了,但登录能完成 } } } fn delete(&self) -> std::io::Result<bool> { // 登出删除:委托给 keyring 后端(它内部会连文件一起删) self.keyring_storage.delete() // 一处调用清干净两个位置 }} // AutoAuthStorage 结束为什么这样设计:这是"安全与可用性冲突时的降级链"。keyring 更安全(OS 加密、访问留痕),但依赖桌面环境——无头服务器、CI、容器里它根本不存在。Auto 模式让同一份代码在两种世界里都能登录:有 keyring 就用,没有就退到 0o600 文件,且每次降级都打 warn 日志。对比 DirectKeyringAuthStorage::save(第 301-310 行)还有个细节:keyring 写成功后会主动删除 auth.json fallback——防止"两个位置各有一份凭证"导致登出一边、另一边残留。
四种模式的行为对照(含 Ephemeral:进程内全局 Mutex<HashMap>,app-server 多会话共享外部 token 用):
四种凭证存储模式对照
File — 明文文件
存哪里 $CODEX_HOME/auth.json,Unix 权限位 0o600读优先级 最低——keyring 不可用或 auto 降级时才轮到它写行为 truncate+write 覆盖写,自动建父目录适用场景 无头服务器 / CI / 没有 keyring 守护进程的环境
Keyring(Direct / Secrets)— 系统钥匙串
存哪里 macOS Keychain / Linux Secret Service,键名 = cli| + sha256(codex_home) 前 16 位读优先级 最高——auto 模式首选位置写行为 写入成功后主动删除 auth.json fallback,防止双份残留适用场景 桌面环境默认选择;Secrets 变体多一层本地加密命名空间
Auto — keyring 优先 + 文件兜底
存哪里 运行时决定:keyring 可用就写 keyring,否则落 auth.json读优先级 先 keyring(命中即返回)→ miss/失败 → 文件降级行为 每次降级都打 warn 日志——可观测的静默兜底适用场景 同一份代码要同时跑在桌面和无头环境
Ephemeral — 进程内内存
存哪里 全局 static Mutex<HashMap<key, AuthDotJson>>,按 codex_home 哈希分键读优先级 最高——load_auth() 永远先查它(外部注入的 token 优先于本地持久凭证)生命周期 进程退出即消失;app-server 用它跨会话共享宿主注入的 ChatGPT token适用场景 托管/嵌入场景:凭证由宿主管理,Codex 只借用不落盘
四、本地 OAuth 服务器:PKCE + state 双保险
codex login 的交互流程是标准 OAuth 授权码模式,但有两个 CLI 特有的安全需求:① 回调地址是 http://localhost:随机端口,没有 client_secret 可保护;② 用户可能同时开多个登录。PKCE(RFC 7636)就是为"公共客户端 + 无 secret"设计的:
📄 codex-rs/login/src/pkce.rs (第 5-27 行)
#[derive(Debug, Clone)]pub struct PkceCodes { // PKCE 两件套:verifier 留在本地,challenge 发给授权服务器 pub code_verifier: String, // 64 随机字节的 base64url——只有发起登录的客户端知道它 pub code_challenge: String, // SHA256(verifier) 再 base64url——放进 authorize URL,服务器存起来等兑换时核对}pub fn generate_pkce() -> PkceCodes { // 每次登录会话生成一对全新 PKCE——绝不跨会话复用 let mut bytes = [0u8; 64]; // 64 字节熵:RFC 7636 要求 verifier 43-128 字符,base64url(64B)=86 字符正好落在区间内 rand::rng().fill_bytes(&mut bytes); // CSPRNG 填充——verifier 可预测 = 整个 PKCE 失效 let code_verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); // URL-safe base64 无 padding:能安全出现在 query string 里,不引入 +/= 转义问题 let digest = Sha256::digest(code_verifier.as_bytes()); // S256 方法:对 verifier 的编码串(不是原始字节)做 SHA-256——RFC 规定的顺序 let code_challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest); // 摘要再编码一次——这才是进 authorize URL 的值 PkceCodes { code_verifier, code_challenge } // verifier 留在内存里,直到 token 交换那一刻才用掉} // generate_pkce 结束为什么这样设计:PKCE 的威胁模型是"authorization code 在传输中被截获"——攻击者拿到 code 去换 token,但没有 verifier 就算不出 challenge 匹配,服务器拒绝。对 CLI 这种没有 client_secret 的公共客户端,这是唯一可靠的防 code 劫持手段。注意 Sha256::digest(code_verifier.as_bytes()):哈希的是编码后的字符串而非原始随机字节——这是 RFC 7636 的精确要求,写反了服务器端校验必挂。
再看本地回调服务器的启动。它要解决一个 CLI 特有的麻烦:端口可能被上一个没退干净的登录进程占着:
📄 codex-rs/login/src/server.rs (第 159-206 行,节选)
/// Starts a local callback server and returns the browser auth URL. // 文档注释:启动本地回调服务器并返回浏览器登录 URLpub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> { // 一次调用完成:生成 PKCE/state → 绑端口 → 开浏览器 → 起异步处理循环 let pkce = generate_pkce(); // 本次登录专属的 PKCE 对——verifier 只活在当前进程内存里 let state = opts.force_state.clone().unwrap_or_else(generate_state); // 32 字节随机数的 base64url:防 CSRF;force_state 留给测试注入固定值 let server = bind_server(opts.port)?; // 绑 127.0.0.1:1455——bind_server 内部处理端口冲突(见下) let actual_port = match server.server_addr().to_ip() { // 回读真实绑定端口:redirect_uri 必须用它,不能假设就是 1455 Some(addr) => addr.port(), // 正常路径:拿到实际端口号 None => { return Err(io::Error::new(io::ErrorKind::AddrInUse, "Unable to determine the server port")); } // 读不到 = 无法构造合法 redirect_uri——fail loud,绝不猜一个端口继续 }; let server = Arc::new(server); // Arc 共享:recv 线程和异步处理任务各持一份引用 let redirect_uri = format!("http://localhost:{actual_port}/auth/callback"); // 注册给 OAuth 服务器的回调地址——必须与 authorize URL 里的完全一致,否则服务器拒发 code let auth_url = build_authorize_url(&opts.issuer, &opts.client_id, &redirect_uri, &pkce, &state, opts.forced_chatgpt_workspace_id.as_deref()); // 拼出完整浏览器登录 URL:含 code_challenge(S256)/state/scope/allowed_workspace_id if opts.open_browser { let _ = webbrowser::open(&auth_url); } // 拉起系统浏览器;失败不致命——用户还能手动复制 URL(无头环境可关此开关) let (tx, mut rx) = tokio::sync::mpsc::channel::<Request>(16); // 阻塞 HTTP → 异步运行时的桥:容量 16,一次登录远用不满 let _server_handle = { // 起一个专用线程跑 tiny_http 的阻塞 recv 循环——tiny_http 是同步 API,不能直接塞进 async runtime let server = server.clone(); thread::spawn(move || -> io::Result<()> { while let Ok(request) = server.recv() { // 阻塞等下一个请求;处理端调 unblock() 后这里返回 Err、循环退出 match tx.blocking_send(request) { // 把请求推给异步侧——blocking_send 保证即使 runtime 繁忙也不丢 Ok(()) => {} // 交接成功,继续等下一个 Err(error) => { eprintln!("Failed to send request to channel: {error}"); return Err(io::Error::other("Failed to send request to channel")); } // 接收端已 drop = 登录流程结束 → 线程干净退出 } } Ok(()) // recv 因 unblock() 返回错误——正常收尾路径 }) };为什么这样设计:三个工程决策值得注意。① 线程桥接:tiny_http 是同步阻塞 API,而 token 交换要 await HTTP——用 mpsc channel 把"收请求"(阻塞线程)和"处理请求"(async task)解耦,两边各用最合适的执行模型。② 端口冲突自愈:bind_server(第 637-702 行)在 AddrInUse 时会先向旧实例发 GET /cancel(第 620-635 行,裸 TCP + HTTP/1.1 手写请求头——不引额外依赖),重试 10 次 × 200ms 后仍失败才切到备用端口 1457(注释明说"Keep in sync with the Codex CLI Hydra redirect URI allow-list"——两个端口都在 OAuth 服务器白名单里)。③ actual_port 回读:redirect_uri 用真实绑定端口而非配置值,杜绝"以为绑了 1455 实际是别的"这类幽灵 bug。
🔒 本讲安全链小结:PKCE verifier(防 code 劫持)+ state 随机数(防 CSRF/回调串号)+ localhost-only 绑定(不暴露到网络)+ 0o600/keyring 存储(防本机其他用户读)。四道防线各挡一类攻击,缺任何一道都会留下可被利用的缺口。
五、回调处理:先验 state,再换 token
浏览器登录完成后会重定向回 /auth/callback?code=...&state=...。处理顺序是安全关键——先验证 state,再碰 code:
📄 codex-rs/login/src/server.rs (第 346-402 行,节选)
"/auth/callback" => { // 浏览器重定向落点:query 里带 code/state,或 error/error_description let params: std::collections::HashMap<String, String> = parsed_url.query_pairs().into_owned().collect(); // 全部 query 参数收进 map——成功/错误分支统一从这里取值 let has_code = params.get("code").is_some_and(|code| !code.is_empty()); // 记录 code 是否存在(只用于日志,不改变控制流) let has_state = params.get("state").is_some_and(|state| !state.is_empty()); // 记录 state 是否存在(同上) let has_error = params.get("error").is_some_and(|error| !error.is_empty()); // OAuth 服务器可能直接回错误而不是 code let callback_result = params.get("state").and_then(|callback_state| login_callback_result_from_state(callback_state, state)); // 核心校验:回调里的 state 必须等于本地生成的随机值——CSRF/串号回调在这里被拦下 let state_valid = callback_result.is_some(); // None = 不匹配或不可解析(state 里还编码了 onboarding 入口点等元数据) if !state_valid { return HandledRequest::Response(Response::from_string("State mismatch").with_status_code(400)); } // state 不符 → 直接 400:哪怕 code 是真的也不换——没有 state 背书,code 不可信 if let Some(error_code) = params.get("error") { // OAuth 服务器返回了错误(用户拒绝授权等) let message = oauth_callback_error_message(error_code, error_description); // 映射成用户可读的失败文案 return login_error_response(&message, io::ErrorKind::PermissionDenied, Some(error_code), error_description); // 渲染错误页并结束登录流程——不进入 token 交换 } let code = match params.get("code") { // 走到这里 state 已验证,才允许取 code Some(c) if !c.is_empty() => c.clone(), // 正常路径:拿到授权码 _ => { return login_error_response("Missing authorization code. Sign-in could not be completed.", io::ErrorKind::InvalidData, Some("missing_authorization_code"), None); } // state 对但没 code = 异常状态,明确报错而不是静默继续 };为什么这样设计:"state 先于 code"的顺序是 OAuth 实现的经典考点。攻击场景:恶意页面诱导浏览器访问一个带别人的合法 code 的回调 URL——如果代码先拿 code 去换 token,就会把别人的凭证存进本机(CSRF)。先验 state 后,这种回调在第一步就被 400 拒绝。另外注意日志纪律:只记 has_code/has_state/state_valid 布尔值,从不记录 code/state 原文——文件头注释(第 5-13 行)明说这是"用户可见错误路径与结构化日志路径分离"的设计:日志里只出现审查过的字段。
state 验证通过后,用 code + verifier 去换真正的三件套:
📄 codex-rs/login/src/server.rs (第 809-883 行,节选)
pub(crate) async fn exchange_code_for_tokens(issuer: &str, client_id: &str, redirect_uri: &str, pkce: &PkceCodes, code: &str, auth_route_config: &AuthRouteConfig) -> io::Result<ExchangedTokens> { // 授权码 → id/access/refresh 三 token:整个登录流程里唯一一次"拿真凭证"的网络请求 #[derive(serde::Deserialize)] struct TokenResponse { // 服务器应答结构:三个字段全必填——缺任何一个都说明交换不完整,反序列化直接失败 id_token: String, access_token: String, refresh_token: String, } let client = create_raw_auth_client(issuer.trim_end_matches('/'), auth_route_config)?; // 复用 issuer 选定的路由(代理策略)——token 端点不单独配置,从 issuer 推导 let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/')); // {issuer}/oauth/token:与 authorize URL 同一个域 let resp = client.post(token_endpoint) // POST 请求 .header("Content-Type", "application/x-www-form-urlencoded") // OAuth2 规范要求 form 编码(不是 JSON)——很多实现踩过的坑 .body(format!("grant_type=authorization_code&code={}&redirect_uri={}&client_id={}&code_verifier={}", urlencoding::encode(code), urlencoding::encode(redirect_uri), urlencoding::encode(client_id), urlencoding::encode(&pkce.code_verifier))) // 五个参数缺一不可:verifier 证明"我就是发起登录的那个客户端"——截获 code 的攻击者没有它 .send().await; // 发出并等待响应 let resp = match resp { Ok(resp) => resp, Err(error) => { let error = redact_sensitive_error_url(error); return Err(io::Error::other(error)); } }; // 传输失败:先脱敏(URL 里可能带敏感 query)再上报——错误信息也是泄露面 if !status.is_success() { /* 解析 TokenEndpointErrorDetail,返回 "token endpoint returned status ..." */ } // 非 2xx:提取 error_code/error_message 拼进错误——支持工程师能直接定位是哪类拒绝 let tokens: TokenResponse = resp.json().await.map_err(io::Error::other)?; // 反序列化三 token;字段缺失会在这里炸,不会带着半套凭证往下走 Ok(ExchangedTokens { id_token: tokens.id_token, access_token: tokens.access_token, refresh_token: tokens.refresh_token }) // 返回三件套——调用方负责解析 claims + 持久化为什么这样设计:TokenResponse 三个字段全是 String(非 Option)——这是"完整性即正确性"的编码方式:服务器少给一个 token,反序列化直接失败,而不是拿到半套凭证继续跑。错误路径上的 redact_sensitive_error_url 容易被忽略但很重要:reqwest 的错误信息里会带完整 URL,而某些部署下 URL query 可能含敏感参数——日志/报错前统一脱敏。urlencoding::encode 对每个参数单独编码,防止 code 里的特殊字符破坏 form 结构。
换到 token 后还有两步收尾(第 415-464 行):① ensure_workspace_allowed——企业策略限制了 workspace 时,id_token 里的 account_id 不在白名单就拒绝登录;② persist_tokens_async(第 886-928 行)把三件套 + 解析后的 claims 组装成 AuthDotJson,spawn_blocking 到线程池执行同步的存储写入——不阻塞 async runtime。最后 302 重定向到成功页(本地或托管),服务器 unblock() 退出。
六、主动刷新:到期前 5 分钟动手
access token 是分钟级有效期。Codex 的策略不是"401 了再刷",而是每次取凭证时顺手检查、临期就主动刷新——入口 AuthManager::auth():
📄 codex-rs/login/src/auth/manager.rs (第 2372-2386 行)
pub async fn auth(&self) -> Option<CodexAuth> { // 所有调用方取凭证的统一入口——可能顺手触发一次主动刷新 if self.has_external_auth() { // 外部托管凭证(workload identity / app-server 注入):本地没有刷新权 self.reload().await; // 每次从 provider 重新 resolve——新鲜度由宿主保证 return self.auth_cached(); // reload 后返回缓存值 } let auth = self.auth_cached()?; // 先读内存缓存——快路径,零 IO;None(未登录)直接短路返回 if Self::should_refresh_proactively(&auth) && let Err(err) = self.refresh_token().await { // 临期 → 主动刷一次;注意 &&:不临期就完全不走刷新分支 tracing::error!("Failed to refresh token: {}", err); // 刷新失败(断网等)——记日志但不让调用方失败 return Some(auth); // 降级返回旧 token:宁可带一个可能过期的 token,也别无凭证可用——真过期了由服务器 401 + unauthorized_recovery 兜底 } self.auth_cached() // 正常路径或刷新成功 → 返回最新缓存(refresh_token 内部已把新值写回)} // auth() 结束为什么这样设计:"刷新失败仍返回旧 token"是可用性优先的关键决策。如果这里直接报错,一次网络抖动就会让所有正在跑的会话瞬间失去凭证;而带旧 token 继续跑,最坏情况只是后续请求收到 401——那时再走 unauthorized_recovery(第 2768-2770 行)强制刷新,是更精确的恢复时机。主动刷新 + 被动恢复双轨制,把"凭证过期"从致命错误降级成可自愈事件。
判断"临期"的逻辑有两级信号——JWT exp 优先、last_refresh 兜底:
📄 codex-rs/login/src/auth/manager.rs (第 2959-2981 行)
fn should_refresh_proactively(auth: &CodexAuth) -> bool { // 判断是否该在到期前主动刷新——只有 ChatGPT OAuth 凭证走这条路 let chatgpt_auth = match auth { CodexAuth::Chatgpt(chatgpt_auth) => chatgpt_auth, _ => return false }; // API key/PAT/Bedrock 等静态凭证没有"临期"概念,直接排除 let auth_dot_json = match chatgpt_auth.current_auth_json() { Some(auth_dot_json) => auth_dot_json, None => return false }; // 取当前 auth.json 快照;拿不到就没有判断依据(保守:不刷) if let Some(tokens) = auth_dot_json.tokens.as_ref() && let Ok(Some(expires_at)) = parse_jwt_expiration(&tokens.access_token) { // 首选信号:从 access token JWT 解出 exp——最精确的到期时间 return expires_at <= Utc::now() + chrono::Duration::minutes(CHATGPT_ACCESS_TOKEN_REFRESH_WINDOW_MINUTES); // 距过期 ≤5 分钟就刷——留缓冲窗口,避免"检查时没过期、请求发出时已过期" } let last_refresh = match auth_dot_json.last_refresh { Some(last_refresh) => last_refresh, None => return false }; // 兜底信号:JWT 没有 exp(或解析失败)→ 看上次刷新时间 last_refresh < Utc::now() - chrono::Duration::days(TOKEN_REFRESH_INTERVAL) // 距上次刷新超过 8 天 → 视为陈旧,主动刷一次(TOKEN_REFRESH_INTERVAL=8)} // should_refresh_proactively 结束为什么这样设计:两级信号是"精确优先、粗粒度兜底"的防御式写法。5 分钟窗口(CHATGPT_ACCESS_TOKEN_REFRESH_WINDOW_MINUTES)覆盖的是时钟偏差 + 请求在途时间——检查通过到 token 真正被服务器验证之间总有间隙,留缓冲才不会踩线。8 天兜底(TOKEN_REFRESH_INTERVAL)处理的是"JWT 格式变了/exp 字段没了"的退化场景:宁可多刷一次(refresh token 交换很便宜),也不要让一个来路不明的旧 access token 一直用下去。
七、刷新失败:永久 vs 瞬时的分类学
refresh token 交换失败时,"该不该重试"取决于失败原因。Codex 把后端错误码映射成内部 reason,再决定 Permanent(别再试了)还是 Transient(稍后重试):
📄 codex-rs/login/src/auth/manager.rs (第 1626-1649 行)
let body = response.text().await.unwrap_or_default(); // 读完整错误响应体——具体失败原因藏在 JSON 里,必须解析tracing::error!("Failed to refresh token: {status}: {body}"); // 原始响应进本地日志(支持排查用);用户侧看到的是下面映射后的文案let code = extract_refresh_token_error_code(&body); // 从 body 提取后端专属错误码:refresh_token_expired / reused / invalidated……// RFC 6749 reports an unusable refresh token as invalid_grant without preserving the legacy subtype. // 源码注释:标准协议只给笼统的 invalid_grant,不保留过期/复用/吊销子类型let is_invalid_grant_bad_request = status == StatusCode::BAD_REQUEST && code.as_deref().is_some_and(|code| code.eq_ignore_ascii_case("invalid_grant")); // 400 + invalid_grant:refresh token 已不可用但无子类型——按终局失败处理let failed = classify_refresh_token_failure(code.as_deref(), &body, is_invalid_grant_bad_request); // 映射成 (reason + 用户文案) 二元组if status == StatusCode::UNAUTHORIZED || failed.reason != RefreshTokenFailedReason::Other || is_invalid_grant_bad_request { // 401 / 已知终局原因 / invalid_grant → 重试没有意义,只会消耗 refresh token(可能触发复用检测!) Err(RefreshTokenError::Permanent(failed)) // Permanent:调用方必须停止重试、提示用户重新登录} else { // 其余非 2xx(5xx、网关错误等)——服务端临时故障 let message = try_parse_error_message(&body); // 尽力从 body 里提取人类可读消息 Err(RefreshTokenError::Transient(std::io::Error::other(format!("Failed to refresh token: {status}: {message}")))) // Transient:调用方可以稍后重试——分类的价值就在这里} // 错误分支结束为什么这样设计:这段代码里藏着一个安全细节:注释明说"重试没有意义,只会消耗 refresh token(可能触发复用检测)"。OAuth 服务器普遍有 refresh token rotation + reuse detection——同一个 refresh token 被用两次会被判定为泄露并吊销整条链。所以对 Permanent 类错误死循环重试,不仅没用还会把账号搞挂。分类学让"该停就停、该等就等"变成类型系统里的事:RefreshTokenError::Permanent/Transient(第 207-213 行)两个变体,调用方 match 一次就知道策略。
reason → 用户文案的映射是固定表(第 1652-1681 行),每种失败都有专属提示:
📄 codex-rs/login/src/auth/manager.rs (第 1657-1678 行,节选)
let normalized_code = code.map(str::to_ascii_lowercase); // 先归一化大小写——后端错误码不应有大小写敏感性let reason = match normalized_code.as_deref() { // 后端错误码 → 内部失败原因:穷举已知、未知归 Other Some("refresh_token_expired") => RefreshTokenFailedReason::Expired, // 自然过期:时间到了,重新登录即可 Some("refresh_token_reused") => RefreshTokenFailedReason::Exhausted, // 复用检测触发(可能凭证泄露)——服务器端安全机制主动拦截 Some("refresh_token_invalidated") => RefreshTokenFailedReason::Revoked, // 被主动吊销:别处登出 / 管理员禁用账号 _ => RefreshTokenFailedReason::Other, // 未知码 → Other:是否终局由 HTTP status + invalid_grant 标志共同决定(见上)};let message = match reason { // 每种原因配固定用户文案——不直接把后端原始文本透传给 UI RefreshTokenFailedReason::Expired => REFRESH_TOKEN_EXPIRED_MESSAGE.to_string(), // "refresh token 已过期,请登出后重新登录" RefreshTokenFailedReason::Exhausted => REFRESH_TOKEN_REUSED_MESSAGE.to_string(), // "refresh token 已被使用过……"——暗示凭证可能泄露,用户该警觉 RefreshTokenFailedReason::Revoked => REFRESH_TOKEN_INVALIDATED_MESSAGE.to_string(), // "refresh token 已被吊销……" RefreshTokenFailedReason::Other => REFRESH_TOKEN_UNKNOWN_MESSAGE.to_string(), // 通用兜底文案:不暴露内部细节};为什么这样设计:"固定文案表"是 i18n + 安全双重要求:后端错误文本不可控(可能含内部信息、语言混杂),直接透传既不安全也不专业;而"复用检测触发"这种 reason 对用户有实际安全含义,值得单独一条文案提醒。四种失败原因的完整对照:
刷新成功后的写回(persist_tokens,第 1572-1595 行)遵循"读旧值 → 合并新 token → 更新 last_refresh → save"的原子语义:只覆盖服务器实际返回的字段(refresh token rotation 时 access/refresh/id 可能部分轮换),last_refresh = Utc::now() 为下一轮的 8 天兜底计时。
八、登出:best-effort 吊销 + 本地清除
codex logout 做两件事:尽力通知服务器吊销 token,然后无条件删除本地凭证。注意顺序——吊销失败不阻塞登出:
📄 codex-rs/login/src/auth/revoke.rs (第 55-77 行 + 第 97-117 行,节选)
pub(super) async fn revoke_auth_tokens(auth_dot_json: Option<&AuthDotJson>, auth_route_config: &AuthRouteConfig) -> Result<(), std::io::Error> { // 登出时的吊销入口:没有可吊销材料时直接 Ok——API key/PAT 走这里就是空操作 let Some((token, kind)) = auth_dot_json.and_then(revocable_token) else { return Ok(()); }; // 选出要吊销的 token;选不出(非 ChatGPT OAuth)→ 无事发生 let endpoint = revoke_token_endpoint(); // 端点解析:CODEX_REVOKE_TOKEN_URL_OVERRIDE > 从 refresh override 推导 /oauth/revoke > 默认 auth.openai.com let client = create_default_auth_client(&endpoint, auth_route_config)?; // 走与登录相同的路由/代理策略 revoke_oauth_token(&client, endpoint.as_str(), token, kind, REVOKE_HTTP_TIMEOUT).await // 10 秒超时——登出绝不能被慢网络挂住(REVOKE_HTTP_TIMEOUT=10s)}fn revocable_token(auth_dot_json: &AuthDotJson) -> Option<(&str, RevokeTokenKind)> { // 选哪个 token 去吊销——优先级:refresh > access let tokens = managed_chatgpt_tokens(auth_dot_json)?; // 只有 ChatGPT OAuth 凭证可吊销(API key/PAT/Bedrock 没有对应的服务器端吊销路径) if !tokens.refresh_token.is_empty() { Some((tokens.refresh_token.as_str(), RevokeTokenKind::Refresh)) } // 优先吊销 refresh token:它是能"造新 access token"的根凭证,灭掉它整条链失效 else if !tokens.access_token.is_empty() { Some((tokens.access_token.as_str(), RevokeTokenKind::Access)) } // 没有 refresh → 退而求其次吊销 access(best effort) else { None } // 两者皆空:本地删干净即可,无需网络请求}async fn revoke_oauth_token(client: &HttpClient, endpoint: &str, token: &str, kind: RevokeTokenKind, timeout: Duration) -> Result<(), std::io::Error> { // 发出 RFC 7009 吊销请求 let request = RevokeTokenRequest { token, token_type_hint: kind.as_str(), client_id: kind.client_id() }; // refresh 吊销必须带 client_id(access 不带)——OAuth 服务器的校验要求 let response = client.post(endpoint).header("Content-Type", "application/json").timeout(timeout).json(&request).send().await.map_err(std::io::Error::other)?; // JSON body + 10s 超时;传输错误直接上抛,由调用方决定降级策略 if status.is_success() { return Ok(()); } // 2xx = 吊销成功(RFC 7009:即使 token 无效也返回 200) Err(std::io::Error::other(format!("failed to revoke {}: {}: {}", kind.as_str(), status, message))) // 非 2xx:拼出含类型/状态码/消息的错误——调用方只 warn,不阻塞登出为什么这样设计:logout_with_revoke(manager.rs 第 967-994 行)的调用序列揭示了优先级:revoke_auth_tokens(...).await 失败只 warn!,然后无条件执行 logout_all_stores()。这是"本地状态一致性 > 服务器端清理"的正确取舍——用户登出的核心诉求是"这台机器上不再有我的凭证";服务器吊销是锦上添花,网络不通时不能让用户卡在登出按钮上。而 refresh token 优先于 access token 的吊销顺序,则是按危害等级排序:refresh 是根凭证,先断根。
九、总结:两种登录方式的完整对照
两条完整数据流——登录写路径与刷新读路径:
登录路径(codex login)
① 生成凭证:generate_pkce() + generate_state()
verifier 留内存、challenge+state 进 authorize URL,拉起系统浏览器。
▼
② 绑回调端口:bind_server(1455)
冲突 → GET /cancel 旧实例 → 重试 10×200ms → 备用端口 1457(均在白名单)。
▼
③ 浏览器回调:/auth/callback?code=…&state=…
先验 state 匹配本地随机值,不符直接 400——CSRF 在此被拦。
▼
④ 换 token:POST {issuer}/oauth/token
form 编码 + code_verifier;返回 id/access/refresh 三件套(字段缺一即失败)。
▼
⑤ 解析 claims:parse_chatgpt_jwt_claims
id_token → IdTokenInfo(plan/user_id/fedramp),raw_jwt 原文保留。
▼
⑥ 持久化:save_auth(按存储模式)
🔹 keyring:写入后删 auth.json fallback🔹 file:truncate+write,Unix 权限位 0o600
▼
⑦ 收尾:302 → 成功页,server.unblock()
recv 线程退出、登录流程结束;AuthManager 缓存新凭证供会话使用。
刷新路径(auth() 触发)
① 触发:should_refresh_proactively
exp ≤5min(首选)或 last_refresh >8天(兜底)→ refresh_token()。
▼
② 单飞 + 重载:refresh_lock(Semaphore=1)
先重读存储——别的进程已刷新则跳过网络请求,避免并发重复消耗 refresh token。
▼
③ 请求权威:POST /oauth/token(grant_type=refresh_token)
🔹 Permanent(过期/复用/吊销/invalid_grant)→ 停止重试,提示重登🔹 Transient(5xx 等)→ 可稍后重试
▼
④ 写回:persist_tokens → set_cached_auth
合并新 token + last_refresh=now,落盘后更新内存缓存并通知 watch 订阅者。
📚 系列导航
← 第 5 讲:Model Provider 抽象与多后端
→ 第 7 讲:Agent Identity / Workload Identity 与 Attestation
关注公众号「AI技术推荐官」获取更多源码解析内容