登录与认证 · ChatGPT、API key 与网关
认证集中在 codex-login:八种凭据统一成 CodexAuth,由 AuthManager 按固定优先级加载、缓存、刷新。本篇讲浏览器登录的本地回调与 PKCE、设备码怎么复用同一套换码流程、多进程共用 auth.json 时怎样安全刷新令牌、凭据存在文件还是系统钥匙串,以及 Agent Identity、workload identity 与模型网关 OAuth 这几种面向企业的认证。
登录与认证 · ChatGPT、API key 与网关
Codex 发给模型的每个请求都要带凭据,app-server 连远端、exec-server 注册执行环境也要。这些都出自同一个 crate:codex-login。它把形形色色的凭据统一成一个枚举,再由一个管理器负责加载、缓存、刷新与登出,其余模块只管问“现在用什么凭据”。
怎么用
codex login 打开浏览器走 ChatGPT 登录,--device-auth 用设备码,--with-api-key、--with-access-token 从 stdin 读 API key 或访问令牌,codex login status 看状态,codex logout 登出。凭据默认存在 $CODEX_HOME/auth.json,config.toml 的 cli_auth_credentials_store 可以改成 keyring 或 auto;托管配置可用 forced_login_method、forced_chatgpt_workspace_id 限定登录方式与工作区。完整说明见手册的安装、登录与升级。
CodexAuth:八种凭据,一个入口
所有凭据都归结为这个枚举:
/// Authentication mechanism used by the current user.
#[derive(Debug, Clone)]
pub enum CodexAuth {
ApiKey(ApiKeyAuth),
Chatgpt(ChatgptAuth),
ChatgptAuthTokens(ChatgptAuthTokens),
Headers(AuthHeaders),
AgentIdentity(AgentIdentityAuth),
PersonalAccessToken(PersonalAccessTokenAuth),
BedrockApiKey(BedrockApiKeyAuth),
BedrockAccessKeys(BedrockAccessKeysAuth),
}(codex-rs/login/src/auth/manager.rs:90)
Chatgpt 是 Codex 自己登录、自己刷新的 ChatGPT 令牌;ChatgptAuthTokens 是宿主应用经 app-server 的 account/login/start 注入的令牌,只放在进程内存里,刷新也由宿主负责;ApiKey 是 OpenAI API key;PersonalAccessToken 与 AgentIdentity 来自环境变量 CODEX_ACCESS_TOKEN;两个 Bedrock 变体服务 Amazon Bedrock。
AuthManager 是唯一的事实来源。注释说得直白:它加载一次后发出 CodexAuth 的克隆,外部改了 auth.json 也要等显式 reload() 才看得见,免得同一次运行里不同模块看到不一致的凭据。加载顺序写在 load_auth(codex-rs/login/src/auth/manager.rs:1488):
codex exec、codex doctor、codex exec-server --remote 等调用方会读 CODEX_API_KEY,交互式 TUI 不读;OPENAI_API_KEY 不在这条链上,TUI 只拿它来预填 API key 登录框。另有一个 ExternalAuth trait,装上之后 AuthManager 不再读上面四个来源,改为向它要凭据、要刷新。仓库里有三个实现:app-server 的 ExternalAuthBridge(宿主注入令牌,刷新时反向发 account/chatgptAuthTokens/refresh 请求,等 10 秒)、按模型提供方配置执行命令取令牌的 BearerTokenRefresher,以及后面要讲的 workload identity。
凭据最终由 codex-model-provider 的 AuthProvider 变成请求头:API key、ChatGPT 令牌与 PAT 走 Authorization: Bearer,有账号时加 ChatGPT-Account-ID,FedRAMP 账号再加 X-OpenAI-Fedramp;Agent Identity 则每次请求现签一个 AgentAssertion。
浏览器登录:本地回调与 PKCE
codex login 先吊销并清掉旧凭据(clear_existing_auth_before_login),再起一个只监听 127.0.0.1 的小 HTTP 服务等 OAuth 回调:
PKCE 的两个值这样生成,verifier 只留在本机,授权 URL 里只放它的哈希:
pub(crate) fn generate_pkce() -> PkceCodes {
let mut bytes = [0u8; 64];
rand::rng().fill_bytes(&mut bytes);
// Verifier: URL-safe base64 without padding (43..128 chars)
let code_verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
// Challenge (S256): BASE64URL-ENCODE(SHA256(verifier)) without padding
let digest = Sha256::digest(code_verifier.as_bytes());
let code_challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
PkceCodes {
code_verifier,
code_challenge,
}
}(codex-rs/login/src/oauth/pkce.rs:14)
几处细节:授权请求的 scope 是 openid profile email offline_access api.connectors.read api.connectors.invoke,并带上 originator 与限定工作区用的 allowed_workspace_id;回调必须先过 state 校验,才去看 code 或错误信息(CallbackParameters::validate);端口被占用时,多半是上一次没结束的登录服务,于是先向它发 GET /cancel,每 200 毫秒重试、最多 10 次,仍不行才换到 1457——注释说这个备用端口要和服务端登记的回调地址白名单保持一致。模块开头的注释还专门强调:返回给用户的错误可以详细,写进日志的只能是审核过的字段和脱敏后的 URL。
设备码:给没有浏览器的机器
--device-auth 先向 {issuer}/api/accounts/deviceauth/usercode 申请用户码,提示用户打开 {issuer}/codex/device 输入,然后按服务端给的间隔轮询 deviceauth/token:返回 403 或 404 表示还没授权,继续等,最长 15 分钟。设计上的巧处在于轮询成功时,服务端回的是授权码加上一对 PKCE 值,客户端接着走和浏览器登录完全相同的 exchange_code_for_tokens,只是回调地址换成 {issuer}/deviceauth/callback(codex-rs/login/src/device_code_auth.rs:182)。两条路径共用换码、工作区校验与持久化代码,差别只在“授权码从哪来”。服务端没开设备码时申请用户码返回 404,Codex 会报出“device code login is not enabled”。
令牌刷新:主动、被动与多进程
ChatGPT 登录拿到的是短期 access token 加长期 refresh token,刷新分两种时机:
- 主动:每次
AuthManager::auth()取凭据前检查,access token 这个 JWT 离过期不到 5 分钟就先刷新;解析不出过期时间时,退而看上次刷新是否已超过 8 天(should_refresh_proactively)。 - 被动:请求撞上 401 时,
UnauthorizedRecovery状态机依次尝试:先从存储重新加载(只在账号 ID 没变时),再用 refresh token 刷新,都不行才把错误交给用户;API key、PAT 等不做恢复。外部凭据则只重试一次,请宿主刷新。
难点在多进程。同一台机器上可能同时开着好几个 Codex,共用一个 auth.json;refresh token 用过一次就作废,两个进程各刷一遍,后刷的那个必然失败。refresh_token 因此先做一次“有保护的重新加载”:
/// Attempt to refresh the token by first performing a guarded reload from
/// the active auth source. If the loaded token differs from the cached token,
/// we can assume that the source already refreshed it. Otherwise, ask the
/// token authority to refresh.
pub async fn refresh_token(&self) -> Result<(), RefreshTokenError> {
// ...
let expected_account_id = auth_before_reload
.as_ref()
.and_then(CodexAuth::get_account_id);
match self
.reload_if_account_id_matches(expected_account_id.as_deref())
.await
{
ReloadOutcome::ReloadedChanged => {
tracing::info!("Skipping token refresh because auth changed after guarded reload.");
Ok(())
}
ReloadOutcome::ReloadedNoChange => self.refresh_token_from_authority_impl().await,
ReloadOutcome::Skipped => {
// ...
}
}
}(codex-rs/login/src/auth/manager.rs:2844)
磁盘上的令牌已经变了,说明别的进程刚刷过,直接用新的;账号 ID 对不上(用户在别处换了账号),则拒绝刷新并提示重新登录。进程内还有一把信号量(refresh_lock)保证同一时刻只有一个刷新在跑。刷新请求打到 https://auth.openai.com/oauth/token,服务端返回 refresh_token_expired、refresh_token_reused、refresh_token_invalidated 时归为永久失败,给出对应的“请重新登录”提示,并记在这份凭据名下不再重试;网络错误之类归为暂时失败。登出时 logout_with_revoke 先尽力向 /oauth/revoke 吊销 refresh token(没有就吊销 access token),失败也照样删本地凭据。
凭据存在哪里
存储抽象成只有 load、save、delete 三个方法的 AuthStorageBackend,按配置挑实现:
fn create_auth_storage_with_store(
codex_home: PathBuf,
mode: AuthCredentialsStoreMode,
keyring_store: Arc<dyn KeyringStore>,
keyring_backend_kind: AuthKeyringBackendKind,
) -> Arc<dyn AuthStorageBackend> {
match mode {
AuthCredentialsStoreMode::File => Arc::new(FileAuthStorage::new(codex_home)),
AuthCredentialsStoreMode::Keyring => {
create_keyring_auth_storage(codex_home, keyring_store, keyring_backend_kind)
}
AuthCredentialsStoreMode::Auto => Arc::new(AutoAuthStorage::new(
codex_home,
keyring_store,
keyring_backend_kind,
)),
AuthCredentialsStoreMode::Ephemeral => Arc::new(EphemeralAuthStorage::new(codex_home)),
}
}(codex-rs/login/src/auth/storage.rs:511)
- File(默认):
$CODEX_HOME/auth.json,Unix 上以0o600权限创建。 - Keyring:钥匙串里服务名是
Codex Auth,账户名是cli|加CODEX_HOME规范路径 SHA-256 的前 16 位十六进制,不同的CODEX_HOME互不干扰。又分两种后端:Direct把整份 JSON 直接存进钥匙串;Secrets把它存进本地加密的 secrets 文件,钥匙串只保管文件密钥。后者由secret_auth_storage特性控制,默认只在 Windows 上打开。写进钥匙串成功后会顺手删掉残留的auth.json。 - Auto:先用钥匙串,读写失败就退回文件,只记一条警告。
- Ephemeral:进程内的全局表,按同样的键区分
CODEX_HOME,宿主注入的令牌就放在这里,进程退出即消失。
钥匙串访问由 codex-keyring-store 封装:它在 keyring crate 之上定义 KeyringStore trait(测试时换成 MockKeyringStore),并按平台打开不同的后端特性——macOS 是 apple-native,Windows 是 windows-native,Linux 是 linux-native-async-persistent,FreeBSD 与 OpenBSD 是 sync-secret-service。
企业与自动化:Agent Identity、workload identity 与网关
面向组织的几种认证走的是另一套思路:不存用户令牌,而是用机器身份。
- Agent Identity:
CODEX_ACCESS_TOKEN若是 Agent Identity JWT,先按 JWKS 验签,得到 agent 运行时 ID 与私钥;也可以由已登录的 ChatGPT 账号现场注册一个。每次启动再向服务端登记一个 task,之后每个请求带AgentAssertion头,内容是用 Ed25519 私钥对“运行时 ID、task ID、时间戳”的签名(codex-rs/agent-identity/src/lib.rs:232)。由 ChatGPT 账号现场注册时,遇到 429、5xx 或网络错误最多试 3 次,仍失败则同一账号冷却 1 小时再试。 - workload identity:设了
OPENAI_FEDERATION_RULE_ID或OPENAI_IDENTITY_TOKEN_FILE任意一个即启用,缺项直接报错而不是退回别的凭据。它把OPENAI_IDENTITY_TOKEN_FILE指向的文件里的身份断言换成 ChatGPT 凭据,作为ExternalAuth装进AuthManager;codex logout对它无效,报错说这种凭据由宿主管理。 - 网关 OAuth:有些模型提供方前面还挡着一层企业网关。
[model_providers.<id>.gateway_oauth]配好授权与令牌端点、client_id、scope 和回调端口后,GatewayAuthManager为这层网关单独走一遍 PKCE 授权,把令牌以指定的请求头或 Cookie 附在请求上,与提供方自己的认证并存。令牌存进加密 secrets 的独立命名空间,离过期不到 30 秒就刷新;多份配置共用一个加密文件,所以换码期间用secrets/gateway_oauth.lock文件锁串行。app-server 用account/gatewayOAuth/read、login、cancel三个方法把登录交给前端。
和《从 LLM 到 Coding Agent》对照
从裸调 API 开始那篇只需要一个 API key。落到一个多端共用、长期运行的产品里,认证就成了一个独立子系统:令牌会过期要刷新,refresh token 只能用一次所以多进程要协调,凭据要进系统钥匙串,出了 401 要有恢复步骤,企业还要能锁定登录方式与工作区。Codex 用一个枚举加一个管理器把这些复杂度收拢,模型客户端、远程控制、exec-server 注册都只跟 AuthManager 打交道。
上一篇:exec-server · 在另一台机器上执行 · 下一篇:实时语音 · 语音对话的实现