# 登录与认证 · ChatGPT、API key 与网关

> 认证集中在 codex-login：八种凭据统一成 CodexAuth，由 AuthManager 按固定优先级加载、缓存、刷新。本篇讲浏览器登录的本地回调与 PKCE、设备码怎么复用同一套换码流程、多进程共用 auth.json 时怎样安全刷新令牌、凭据存在文件还是系统钥匙串，以及 Agent Identity、workload identity 与模型网关 OAuth 这几种面向企业的认证。

- 作者：David（道雾轩）
- 专栏：Codex 源码解读（https://daiw.net/manual/codex-source.md）
- 最后更新：2026-09-29
- 原文：https://daiw.net/manual/codex-source/login-and-auth
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

# 登录与认证 · ChatGPT、API key 与网关

Codex 发给模型的每个请求都要带凭据，app-server 连远端、[exec-server](https://daiw.net/manual/codex-source/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` 限定登录方式与工作区。完整说明见手册的[安装、登录与升级](https://daiw.net/manual/codex/installation)。

## CodexAuth：八种凭据，一个入口

所有凭据都归结为这个枚举：

```rust
/// 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`）：

```mermaid
flowchart LR
  E1[环境变量 CODEX_API_KEY<br/>调用方开启时才读] --> L{load_auth<br/>取第一个命中的}
  E2[进程内存储<br/>宿主注入的 ChatGPT 令牌] --> L
  E3[环境变量 CODEX_ACCESS_TOKEN<br/>PAT 或 Agent Identity JWT] --> L
  E4[持久存储<br/>auth.json 或系统钥匙串] --> L
  L --> M[AuthManager<br/>缓存 CodexAuth]
  X[ExternalAuth<br/>装上后完全接管] --> M
  M --> P[AuthProvider 生成请求头]
  P --> H1[Bearer 令牌<br/>ChatGPT-Account-ID]
  P --> H2[AgentAssertion 签名]
```

`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 回调：

```mermaid
sequenceDiagram
  participant C as codex login
  participant S as 本地回调服务
  participant B as 用户浏览器
  participant A as auth.openai.com
  C->>C: 生成 PKCE verifier 与随机 state
  C->>S: 绑定 1455，被占用则先发 /cancel，再退到 1457
  C->>B: 打开 /oauth/authorize，带 code_challenge 与 state
  B->>A: 登录并授权
  A-->>B: 302 到 /auth/callback，带 code 与 state
  B->>S: GET /auth/callback
  S->>S: 校验 state
  S->>A: POST /oauth/token，code 加 code_verifier
  A-->>S: id_token、access_token、refresh_token
  S->>A: token exchange 换一个 API key，失败不影响登录
  S->>S: 校验工作区限制，写入凭据存储
  S-->>B: 302 到 /success
```

PKCE 的两个值这样生成，verifier 只留在本机，授权 URL 里只放它的哈希：

```rust
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` 因此先做一次“有保护的重新加载”：

```rust
    /// 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`，按配置挑实现：

```rust
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 开始](https://daiw.net/manual/llm-to-agent/raw-llm-api)那篇只需要一个 API key。落到一个多端共用、长期运行的产品里，认证就成了一个独立子系统：令牌会过期要刷新，refresh token 只能用一次所以多进程要协调，凭据要进系统钥匙串，出了 401 要有恢复步骤，企业还要能锁定登录方式与工作区。Codex 用一个枚举加一个管理器把这些复杂度收拢，模型客户端、远程控制、exec-server 注册都只跟 `AuthManager` 打交道。

---

上一篇：[exec-server · 在另一台机器上执行](https://daiw.net/manual/codex-source/exec-server) · 下一篇：[实时语音 · 语音对话的实现](https://daiw.net/manual/codex-source/realtime-voice)
