From df7818c7d138ad081f5bd1fa584646c116da7022 Mon Sep 17 00:00:00 2001 From: cooper-oai Date: Fri, 5 Jun 2026 17:36:18 -0700 Subject: [PATCH] [codex-rs] support v2 personal access tokens (#25731) ## Summary - add v2 personal access token support for `codex login --with-access-token` and `CODEX_ACCESS_TOKEN` - classify opaque `at-` tokens separately from legacy Agent Identity JWTs - hydrate required ChatGPT account metadata through AuthAPI `/v1/user-auth-credential/whoami` - use PATs directly as bearer tokens while preserving existing ChatGPT account surfaces - expose PAT-backed auth as the explicit `personalAccessToken` app-server auth mode ## Implementation PAT auth is intentionally small and stateless. Loading a PAT performs one AuthAPI metadata request, stores the hydrated metadata in the in-memory auth object, and redacts the secret from debug output. Legacy Agent Identity JWT handling remains unchanged. The shared access-token classifier lives in a private neutral module because it dispatches between both credential types. PAT hydration fails closed when AuthAPI omits any required metadata, including email. Hydrated metadata is intentionally not persisted: startup performs a live `whoami` preflight so revoked tokens or changed account metadata are not accepted from a stale cache. ## Workspace restriction scope This change intentionally does **not** apply `forced_chatgpt_workspace_id` to PAT authentication. The setting is a client-side config guardrail, not an authorization boundary, and PAT does not currently require workspace-ID parity. The PAT login and `CODEX_ACCESS_TOKEN` paths therefore validate through AuthAPI without threading workspace-restriction state through access-token loading. Existing workspace checks for non-PAT auth remain on their established paths. ## App-server compatibility The public app-server `AuthMode` is shared across v1 and v2, and PAT-backed auth reports `personalAccessToken` through both APIs. Following human review, this intentionally removes the temporary v1 compatibility mapping that reported PATs as `chatgpt`; the deprecated v1 API is kept in parity with v2 rather than maintaining a separate closed enum. Clients with exhaustive auth-mode handling in either API version must add the new case and should generally treat it as ChatGPT-backed unless they need PAT-specific behavior. The v1 auth-status response still omits the raw PAT when `includeToken` is requested because that response cannot carry the account metadata needed to reuse the credential safely. Persisted PAT auth also omits the new enum value so older Codex builds can deserialize `auth.json` and infer PAT auth from the credential field after a rollback. ## Validation Latest review-fix validation: - `CARGO_INCREMENTAL=0 just test -p codex-login` (126 passed) - `CARGO_INCREMENTAL=0 just test -p codex-cli` (263 passed) - `CARGO_INCREMENTAL=0 just test -p codex-cli stored_auth_validation_handles_personal_access_token` - `CARGO_INCREMENTAL=0 just test -p codex-app-server-protocol` (226 passed) - `CARGO_INCREMENTAL=0 just test -p codex-models-manager refresh_available_models_uses_remote_only_catalog_for_chatgpt_auth` - `CARGO_INCREMENTAL=0 just test -p codex-tui existing_non_oauth_chatgpt_login_counts_as_signed_in` - `CARGO_INCREMENTAL=0 just fix -p codex-login -p codex-app-server-protocol -p codex-models-manager -p codex-tui -p codex-cli` - `just fmt` - `git diff --check` The broader `codex-tui` suite previously compiled and ran 2,834 tests. Three unrelated environment-sensitive guardian/IDE-socket tests failed after retries; the PAT-relevant TUI coverage passed. --- .../schema/json/ServerNotification.json | 7 + .../codex_app_server_protocol.schemas.json | 7 + .../codex_app_server_protocol.v2.schemas.json | 7 + .../json/v2/AccountUpdatedNotification.json | 7 + .../schema/typescript/AuthMode.ts | 2 +- .../src/protocol/common.rs | 15 ++ .../src/transport/remote_control/tests.rs | 1 + .../src/transport/remote_control/websocket.rs | 1 + codex-rs/app-server/README.md | 3 +- codex-rs/app-server/src/request_processors.rs | 1 - .../request_processors/account_processor.rs | 44 ++--- .../app-server/tests/common/auth_fixtures.rs | 1 + codex-rs/app-server/tests/suite/auth.rs | 59 ++++++ .../app-server/tests/suite/v2/app_list.rs | 1 + codex-rs/chatgpt/src/workspace_settings.rs | 12 +- codex-rs/cli/src/doctor.rs | 41 +++- codex-rs/cli/src/login.rs | 4 + codex-rs/core/src/client.rs | 7 +- codex-rs/core/tests/suite/cli_stream.rs | 125 ++++++++++++ codex-rs/login/src/auth/access_token.rs | 18 ++ codex-rs/login/src/auth/access_token_tests.rs | 13 ++ codex-rs/login/src/auth/auth_tests.rs | 184 ++++++++++++++++++ codex-rs/login/src/auth/manager.rs | 160 ++++++++++++--- codex-rs/login/src/auth/mod.rs | 2 + .../login/src/auth/personal_access_token.rs | 112 +++++++++++ .../src/auth/personal_access_token_tests.rs | 71 +++++++ codex-rs/login/src/auth/storage.rs | 3 + codex-rs/login/src/auth/storage_tests.rs | 28 +++ codex-rs/login/src/server.rs | 16 ++ codex-rs/login/tests/suite/auth_refresh.rs | 25 +++ codex-rs/login/tests/suite/logout.rs | 1 + codex-rs/model-provider-info/src/lib.rs | 7 +- .../src/model_provider_info_tests.rs | 9 + codex-rs/model-provider/src/auth.rs | 15 +- codex-rs/model-provider/src/provider.rs | 18 +- codex-rs/models-manager/src/manager.rs | 7 +- codex-rs/models-manager/src/manager_tests.rs | 1 + codex-rs/otel/src/lib.rs | 3 +- codex-rs/tui/src/app/app_server_events.rs | 7 +- codex-rs/tui/src/app_server_session.rs | 3 +- codex-rs/tui/src/local_chatgpt_auth.rs | 2 + codex-rs/tui/src/onboarding/auth.rs | 27 +-- 42 files changed, 980 insertions(+), 97 deletions(-) create mode 100644 codex-rs/login/src/auth/access_token.rs create mode 100644 codex-rs/login/src/auth/access_token_tests.rs create mode 100644 codex-rs/login/src/auth/personal_access_token.rs create mode 100644 codex-rs/login/src/auth/personal_access_token_tests.rs diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 010ec33e0..96b24f932 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -525,6 +525,13 @@ "agentIdentity" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a personal access token.", + "enum": [ + "personalAccessToken" + ], + "type": "string" } ] }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a5578e007..15ed98e35 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -6677,6 +6677,13 @@ "agentIdentity" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a personal access token.", + "enum": [ + "personalAccessToken" + ], + "type": "string" } ] }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 36b95b4f6..ebf8796cd 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -999,6 +999,13 @@ "agentIdentity" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a personal access token.", + "enum": [ + "personalAccessToken" + ], + "type": "string" } ] }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json index e7546f557..013592c9e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json @@ -31,6 +31,13 @@ "agentIdentity" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a personal access token.", + "enum": [ + "personalAccessToken" + ], + "type": "string" } ] }, diff --git a/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts b/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts index 210e54c4a..1cb6ccb6b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts +++ b/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts @@ -5,4 +5,4 @@ /** * Authentication mode for OpenAI-backed providers. */ -export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity"; +export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity" | "personalAccessToken"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 1c996d7cb..d3c8eae22 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -36,6 +36,21 @@ pub enum AuthMode { #[ts(rename = "agentIdentity")] #[strum(serialize = "agentIdentity")] AgentIdentity, + /// Programmatic Codex auth backed by a personal access token. + #[serde(rename = "personalAccessToken")] + #[ts(rename = "personalAccessToken")] + #[strum(serialize = "personalAccessToken")] + PersonalAccessToken, +} + +impl AuthMode { + /// Returns whether this mode represents an authenticated human ChatGPT account. + pub fn has_chatgpt_account(self) -> bool { + match self { + Self::Chatgpt | Self::ChatgptAuthTokens | Self::PersonalAccessToken => true, + Self::ApiKey | Self::AgentIdentity => false, + } + } } macro_rules! experimental_reason_expr { diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index 91c13515d..69659e84a 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -115,6 +115,7 @@ fn remote_control_auth_dot_json(account_id: Option<&str>) -> AuthDotJson { }), last_refresh: Some(chrono::Utc::now()), agent_identity: None, + personal_access_token: None, } } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 43fce8ed3..86b72849d 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -1769,6 +1769,7 @@ mod tests { }), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, } } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a12144238..3e294d4cf 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1770,6 +1770,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco - **API key (`apiKey`)**: Caller supplies an OpenAI API key via `account/login/start` with `type: "apiKey"`. The API key is saved and used for API requests. - **ChatGPT managed (`chatgpt`)** (recommended): Codex owns the ChatGPT OAuth flow and refresh tokens. Start via `account/login/start` with `type: "chatgpt"` for the browser flow or `type: "chatgptDeviceCode"` for device code; Codex persists tokens to disk and refreshes them automatically. +- **Personal access token (`personalAccessToken`)**: Codex uses a ChatGPT-backed personal access token loaded outside the app-server login RPCs, such as with `codex login --with-access-token` or `CODEX_ACCESS_TOKEN`. ### API Overview @@ -1778,7 +1779,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco - `account/login/completed` (notify) — emitted when a login attempt finishes (success or error). - `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`. - `account/logout` — sign out; triggers `account/updated`. -- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available. +- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, `personalAccessToken`, or `null`) and includes the current ChatGPT `planType` when available. - `account/rateLimits/read` — fetch ChatGPT rate limits and an optional effective monthly credit limit; updates arrive via `account/rateLimits/updated` (notify). - `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets. - `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 0266cb9d9..57205798b 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -357,7 +357,6 @@ use codex_mcp::discover_supported_scopes; use codex_mcp::read_mcp_resource as read_mcp_resource_without_thread; use codex_mcp::resolve_oauth_scopes; use codex_memories_write::clear_memory_roots_contents; -use codex_model_provider::ProviderAccountError; use codex_model_provider::create_model_provider; use codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; use codex_protocol::ThreadId; diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 21160d681..a4bbe42d6 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -776,24 +776,28 @@ impl AccountRequestProcessor { let permanent_refresh_failure = self.auth_manager.refresh_failure_for_auth(&auth).is_some(); let auth_mode = auth.api_auth_mode(); - let (reported_auth_method, token_opt) = - if matches!(auth, CodexAuth::AgentIdentity(_)) - || include_token && permanent_refresh_failure - { - (Some(auth_mode), None) - } else { - match auth.get_token() { - Ok(token) if !token.is_empty() => { - let tok = if include_token { Some(token) } else { None }; - (Some(auth_mode), tok) - } - Ok(_) => (None, None), - Err(err) => { - tracing::warn!("failed to get token for auth status: {err}"); - (None, None) - } + let (reported_auth_method, token_opt) = if matches!( + auth, + CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) + ) || include_token + && permanent_refresh_failure + { + // This response cannot represent the metadata needed to reuse these + // credentials. + (Some(auth_mode), None) + } else { + match auth.get_token() { + Ok(token) if !token.is_empty() => { + let tok = if include_token { Some(token) } else { None }; + (Some(auth_mode), tok) } - }; + Ok(_) => (None, None), + Err(err) => { + tracing::warn!("failed to get token for auth status: {err}"); + (None, None) + } + } + }; GetAuthStatusResponse { auth_method: reported_auth_method, auth_token: token_opt, @@ -825,11 +829,7 @@ impl AccountRequestProcessor { ); let account_state = match provider.account_state() { Ok(account_state) => account_state, - Err(ProviderAccountError::MissingChatgptAccountDetails) => { - return Err(invalid_request( - "email and plan type are required for chatgpt authentication", - )); - } + Err(err) => return Err(invalid_request(err.to_string())), }; let account = account_state.account.map(Account::from); diff --git a/codex-rs/app-server/tests/common/auth_fixtures.rs b/codex-rs/app-server/tests/common/auth_fixtures.rs index 86f0fb456..cf78e788d 100644 --- a/codex-rs/app-server/tests/common/auth_fixtures.rs +++ b/codex-rs/app-server/tests/common/auth_fixtures.rs @@ -164,6 +164,7 @@ pub fn write_chatgpt_auth( tokens: Some(tokens), last_refresh, agent_identity: None, + personal_access_token: None, }; save_auth(codex_home, &auth, cli_auth_credentials_store_mode).context("write auth.json") diff --git a/codex-rs/app-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs index b1ef99cb7..6e88866c7 100644 --- a/codex-rs/app-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -21,6 +21,7 @@ use tokio::time::timeout; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; +use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; @@ -160,6 +161,64 @@ async fn get_auth_status_with_api_key() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/user-auth-credential/whoami")) + .and(header("Authorization", "Bearer at-test-token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "email": "user@example.com", + "chatgpt_user_id": "user-123", + "chatgpt_account_id": "account-123", + "chatgpt_plan_type": "pro", + "chatgpt_account_is_fedramp": false, + }))) + .expect(1..) + .mount(&server) + .await; + + let authapi_base_url = server.uri(); + let mut mcp = TestAppServer::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ("CODEX_ACCESS_TOKEN", Some("at-test-token")), + ("CODEX_AUTHAPI_BASE_URL", Some(authapi_base_url.as_str())), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(false), + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let status: GetAuthStatusResponse = to_response(resp)?; + assert_eq!( + status, + GetAuthStatusResponse { + auth_method: Some(AuthMode::PersonalAccessToken), + auth_token: None, + requires_openai_auth: Some(true), + } + ); + + server.verify().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_auth_status_with_api_key_when_auth_not_required() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 2177106f8..c9615d3a2 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -118,6 +118,7 @@ async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }, AuthCredentialsStoreMode::File, )?; diff --git a/codex-rs/chatgpt/src/workspace_settings.rs b/codex-rs/chatgpt/src/workspace_settings.rs index a17721551..d5875adcf 100644 --- a/codex-rs/chatgpt/src/workspace_settings.rs +++ b/codex-rs/chatgpt/src/workspace_settings.rs @@ -3,7 +3,6 @@ use std::sync::RwLock; use std::time::Duration; use std::time::Instant; -use anyhow::Context; use codex_core::config::Config; use codex_login::CodexAuth; use serde::Deserialize; @@ -93,20 +92,17 @@ pub async fn codex_plugins_enabled_for_workspace( return Ok(true); } - let token_data = auth - .get_token_data() - .context("ChatGPT token data is not available")?; - if !token_data.id_token.is_workspace_account() { + if !auth.is_workspace_account() { return Ok(true); } - let Some(account_id) = token_data.account_id.as_deref().filter(|id| !id.is_empty()) else { + let Some(account_id) = auth.get_account_id().filter(|id| !id.is_empty()) else { return Ok(true); }; let cache_key = WorkspaceSettingsCacheKey { chatgpt_base_url: config.chatgpt_base_url.clone(), - account_id: account_id.to_string(), + account_id: account_id.clone(), }; if let Some(cache) = cache && let Some(enabled) = cache.get_codex_plugins_enabled(&cache_key) @@ -114,7 +110,7 @@ pub async fn codex_plugins_enabled_for_workspace( return Ok(enabled); } - let encoded_account_id = encode_path_segment(account_id); + let encoded_account_id = encode_path_segment(&account_id); let settings: WorkspaceSettingsResponse = chatgpt_get_request_with_timeout( config, format!("/accounts/{encoded_account_id}/settings"), diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index 2e53d5d51..dde6fad47 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -1308,6 +1308,7 @@ fn stored_auth_mode(auth: &codex_login::AuthDotJson) -> &'static str { codex_app_server_protocol::AuthMode::Chatgpt => "chatgpt", codex_app_server_protocol::AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens", codex_app_server_protocol::AuthMode::AgentIdentity => "agent_identity", + codex_app_server_protocol::AuthMode::PersonalAccessToken => "personal_access_token", } } @@ -1317,6 +1318,8 @@ fn stored_auth_mode_value(auth: &AuthDotJson) -> codex_app_server_protocol::Auth } if auth.openai_api_key.is_some() { codex_app_server_protocol::AuthMode::ApiKey + } else if auth.personal_access_token.is_some() { + codex_app_server_protocol::AuthMode::PersonalAccessToken } else { codex_app_server_protocol::AuthMode::Chatgpt } @@ -1380,6 +1383,15 @@ fn stored_auth_issues( issues.push("agent identity auth is missing an agent identity token"); } } + codex_app_server_protocol::AuthMode::PersonalAccessToken => { + if auth + .personal_access_token + .as_deref() + .is_none_or(|token| token.trim().is_empty()) + { + issues.push("personal access token auth is missing a personal access token"); + } + } } issues } @@ -2408,6 +2420,7 @@ fn auth_mode_name(auth: &CodexAuth) -> &'static str { codex_app_server_protocol::AuthMode::Chatgpt => "chatgpt", codex_app_server_protocol::AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens", codex_app_server_protocol::AuthMode::AgentIdentity => "agent_identity", + codex_app_server_protocol::AuthMode::PersonalAccessToken => "personal_access_token", } } @@ -2541,7 +2554,8 @@ fn provider_auth_reachability_mode_from_auth( Some( codex_app_server_protocol::AuthMode::Chatgpt | codex_app_server_protocol::AuthMode::ChatgptAuthTokens - | codex_app_server_protocol::AuthMode::AgentIdentity, + | codex_app_server_protocol::AuthMode::AgentIdentity + | codex_app_server_protocol::AuthMode::PersonalAccessToken, ) | None => ProviderAuthReachabilityMode::Chatgpt, } @@ -3401,6 +3415,7 @@ mod tests { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; assert_eq!( @@ -3418,6 +3433,7 @@ mod tests { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; assert_eq!( @@ -3429,6 +3445,28 @@ mod tests { ); } + #[test] + fn stored_auth_validation_handles_personal_access_token() { + let mut auth = AuthDotJson { + auth_mode: None, + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: None, + personal_access_token: Some("at-test".to_string()), + }; + + assert_eq!(stored_auth_mode(&auth), "personal_access_token"); + assert!(stored_auth_issues(&auth, |_| false).is_empty()); + + auth.auth_mode = Some(codex_app_server_protocol::AuthMode::PersonalAccessToken); + auth.personal_access_token = None; + assert_eq!( + stored_auth_issues(&auth, |_| false), + vec!["personal access token auth is missing a personal access token"] + ); + } + #[test] fn provider_reachability_mode_uses_api_key_auth() { let api_key_auth = AuthDotJson { @@ -3437,6 +3475,7 @@ mod tests { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; assert_eq!( diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 6fdc62fdb..b3fc1bd54 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -391,6 +391,10 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { eprintln!("Logged in using access token"); std::process::exit(0); } + AuthMode::PersonalAccessToken => { + eprintln!("Logged in using personal access token"); + std::process::exit(0); + } }, Ok(None) => { eprintln!("Not logged in"); diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index ee8ea3619..682e84c7f 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1950,9 +1950,10 @@ impl AuthRequestTelemetryContext { Self { auth_mode: auth_mode.map(|mode| match mode { AuthMode::ApiKey => "ApiKey", - AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens | AuthMode::AgentIdentity => { - "Chatgpt" - } + AuthMode::Chatgpt + | AuthMode::ChatgptAuthTokens + | AuthMode::AgentIdentity + | AuthMode::PersonalAccessToken => "Chatgpt", }), auth_header_attached: auth_telemetry.attached, auth_header_name: auth_telemetry.name, diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index 361096943..fc9b7d256 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -1,14 +1,27 @@ use assert_cmd::Command as AssertCommand; use codex_git_utils::collect_git_info; +use codex_login::CODEX_ACCESS_TOKEN_ENV_VAR; use codex_login::CODEX_API_KEY_ENV_VAR; use codex_protocol::protocol::GitInfo; use core_test_support::fs_wait; use core_test_support::responses; use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; use std::time::Duration; use tempfile::TempDir; use uuid::Uuid; +use wiremock::Mock; use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +const PERSONAL_ACCESS_TOKEN: &str = "at-cli-test"; +const PERSONAL_ACCESS_TOKEN_AUTHORIZATION: &str = "Bearer at-cli-test"; +const PERSONAL_ACCESS_TOKEN_ACCOUNT_ID: &str = "account-pat"; +const WHOAMI_PATH: &str = "/v1/user-auth-credential/whoami"; +const CLOUD_CONFIG_BUNDLE_PATH: &str = "/backend-api/wham/config/bundle"; fn repo_root() -> std::path::PathBuf { #[expect(clippy::expect_used)] @@ -23,6 +36,118 @@ fn cli_sse_response() -> String { ]) } +async fn mount_personal_access_token_startup(server: &MockServer) { + Mock::given(method("GET")) + .and(path(WHOAMI_PATH)) + .and(header("authorization", PERSONAL_ACCESS_TOKEN_AUTHORIZATION)) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "email": "user@example.com", + "chatgpt_user_id": "user-pat", + "chatgpt_account_id": PERSONAL_ACCESS_TOKEN_ACCOUNT_ID, + "chatgpt_plan_type": "enterprise", + "chatgpt_account_is_fedramp": true, + }))) + .expect(1..) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(CLOUD_CONFIG_BUNDLE_PATH)) + .and(header("authorization", PERSONAL_ACCESS_TOKEN_AUTHORIZATION)) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .expect(1) + .mount(server) + .await; +} + +#[expect(clippy::unwrap_used)] +fn personal_access_token_exec_command(server: &MockServer, home: &TempDir) -> AssertCommand { + let bin = codex_utils_cargo_bin::cargo_bin("codex").unwrap(); + let mut cmd = AssertCommand::new(bin); + cmd.timeout(Duration::from_secs(30)); + cmd.arg("exec") + .arg("--skip-git-repo-check") + .arg("-c") + .arg(format!("openai_base_url=\"{}/api/codex\"", server.uri())) + .arg("-c") + .arg(format!("chatgpt_base_url=\"{}/backend-api\"", server.uri())) + .arg("-C") + .arg(repo_root()) + .arg("hello?"); + cmd.env("CODEX_HOME", home.path()) + .env(CODEX_ACCESS_TOKEN_ENV_VAR, PERSONAL_ACCESS_TOKEN) + .env("CODEX_AUTHAPI_BASE_URL", server.uri()) + .env_remove(CODEX_API_KEY_ENV_VAR) + .env_remove("OPENAI_API_KEY"); + cmd +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_mode_stream_cli_supports_personal_access_tokens() { + skip_if_no_network!(); + + let server = MockServer::start().await; + mount_personal_access_token_startup(&server).await; + let resp_mock = responses::mount_sse_once(&server, cli_sse_response()).await; + let home = TempDir::new().unwrap(); + + let output = personal_access_token_exec_command(&server, &home) + .output() + .unwrap(); + + assert!( + output.status.success(), + "codex-cli exec failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let request = resp_mock.single_request(); + assert_eq!(request.path(), "/api/codex/responses"); + assert_eq!( + request.header("authorization").as_deref(), + Some("Bearer at-cli-test") + ); + assert_eq!( + request.header("chatgpt-account-id").as_deref(), + Some(PERSONAL_ACCESS_TOKEN_ACCOUNT_ID) + ); + assert_eq!(request.header("x-openai-fedramp").as_deref(), Some("true")); + server.verify().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_mode_stream_cli_does_not_attempt_oauth_refresh_for_personal_access_tokens_after_401() + { + skip_if_no_network!(); + + let server = MockServer::start().await; + mount_personal_access_token_startup(&server).await; + Mock::given(method("POST")) + .and(path("/api/codex/responses")) + .and(header("authorization", PERSONAL_ACCESS_TOKEN_AUTHORIZATION)) + .and(header( + "chatgpt-account-id", + PERSONAL_ACCESS_TOKEN_ACCOUNT_ID, + )) + .and(header("x-openai-fedramp", "true")) + .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized")) + .expect(1..) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&server) + .await; + let home = TempDir::new().unwrap(); + + let output = personal_access_token_exec_command(&server, &home) + .output() + .unwrap(); + + assert!(!output.status.success()); + server.verify().await; +} + /// Tests streaming the Responses API through the CLI using a mock server. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_mode_stream_cli() { diff --git a/codex-rs/login/src/auth/access_token.rs b/codex-rs/login/src/auth/access_token.rs new file mode 100644 index 000000000..5859ab1c2 --- /dev/null +++ b/codex-rs/login/src/auth/access_token.rs @@ -0,0 +1,18 @@ +const PERSONAL_ACCESS_TOKEN_PREFIX: &str = "at-"; + +pub(super) enum CodexAccessToken<'a> { + PersonalAccessToken(&'a str), + AgentIdentityJwt(&'a str), +} + +pub(super) fn classify_codex_access_token(access_token: &str) -> CodexAccessToken<'_> { + if access_token.starts_with(PERSONAL_ACCESS_TOKEN_PREFIX) { + CodexAccessToken::PersonalAccessToken(access_token) + } else { + CodexAccessToken::AgentIdentityJwt(access_token) + } +} + +#[cfg(test)] +#[path = "access_token_tests.rs"] +mod tests; diff --git a/codex-rs/login/src/auth/access_token_tests.rs b/codex-rs/login/src/auth/access_token_tests.rs new file mode 100644 index 000000000..d734d149d --- /dev/null +++ b/codex-rs/login/src/auth/access_token_tests.rs @@ -0,0 +1,13 @@ +use super::*; + +#[test] +fn classifies_personal_access_tokens_by_prefix() { + assert!(matches!( + classify_codex_access_token("at-example"), + CodexAccessToken::PersonalAccessToken("at-example") + )); + assert!(matches!( + classify_codex_access_token("header.payload.signature"), + CodexAccessToken::AgentIdentityJwt("header.payload.signature") + )); +} diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 63ab3e0c4..e7b5502eb 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -19,6 +19,7 @@ use tempfile::tempdir; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; +use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; @@ -126,6 +127,84 @@ async fn login_with_access_token_writes_only_token() { server.verify().await; } +#[tokio::test] +#[serial(codex_auth_env)] +async fn login_with_access_token_writes_only_personal_access_token() { + let dir = tempdir().unwrap(); + let auth_path = dir.path().join("auth.json"); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/user-auth-credential/whoami")) + .and(header("authorization", "Bearer at-login-test")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(personal_access_token_whoami(WORKSPACE_ID_ALLOWED)), + ) + .expect(1) + .mount(&server) + .await; + let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); + super::login_with_access_token( + dir.path(), + "at-login-test", + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await + .expect("personal access token login should succeed"); + + let storage = FileAuthStorage::new(dir.path().to_path_buf()); + let auth = storage + .try_read_auth_json(&auth_path) + .expect("auth.json should parse"); + assert_eq!( + auth, + AuthDotJson { + auth_mode: None, + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: None, + personal_access_token: Some("at-login-test".to_string()), + } + ); + assert_eq!(auth.resolved_mode(), AuthMode::PersonalAccessToken); + let persisted: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(auth_path).unwrap()).unwrap(); + assert!(persisted.get("auth_mode").is_none()); + server.verify().await; +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn login_with_access_token_rejects_invalid_personal_access_token() { + let dir = tempdir().unwrap(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/user-auth-credential/whoami")) + .respond_with(ResponseTemplate::new(403)) + .expect(1) + .mount(&server) + .await; + let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); + + let err = super::login_with_access_token( + dir.path(), + "at-invalid-login", + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await + .expect_err("invalid personal access token should fail"); + + assert_eq!(err.kind(), std::io::ErrorKind::Other); + assert!( + !get_auth_file(dir.path()).exists(), + "invalid personal access token should not write auth.json" + ); + server.verify().await; +} + #[tokio::test] async fn login_with_access_token_rejects_invalid_jwt() { let dir = tempdir().unwrap(); @@ -245,6 +324,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() { }), last_refresh: Some(last_refresh), agent_identity: None, + personal_access_token: None, }, auth_dot_json ); @@ -286,6 +366,7 @@ fn logout_removes_auth_file() -> Result<(), std::io::Error> { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; super::save_auth(dir.path(), &auth_dot_json, AuthCredentialsStoreMode::File)?; let auth_file = get_auth_file(dir.path()); @@ -762,6 +843,98 @@ async fn load_auth_reads_access_token_from_env() { server.verify().await; } +#[tokio::test] +#[serial(codex_auth_env)] +async fn load_auth_reads_personal_access_token_from_env() { + let codex_home = tempdir().unwrap(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/user-auth-credential/whoami")) + .and(header("authorization", "Bearer at-env-test")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(personal_access_token_whoami(WORKSPACE_ID_ALLOWED)), + ) + .expect(2) + .mount(&server) + .await; + let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); + let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-env-test"); + + for auth_credentials_store_mode in [ + AuthCredentialsStoreMode::File, + AuthCredentialsStoreMode::Ephemeral, + ] { + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + auth_credentials_store_mode, + /*chatgpt_base_url*/ None, + ) + .await + .expect("env auth should load") + .expect("env auth should be present"); + + assert_eq!(auth.api_auth_mode(), AuthMode::PersonalAccessToken); + assert_eq!( + auth.get_token() + .expect("personal access token should be exposed"), + "at-env-test" + ); + assert_eq!(auth.get_account_id().as_deref(), Some(WORKSPACE_ID_ALLOWED)); + assert_eq!(auth.get_chatgpt_user_id().as_deref(), Some("user-123")); + assert_eq!( + auth.get_account_email().as_deref(), + Some("user@example.com") + ); + assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Business)); + assert!(auth.is_fedramp_account()); + } + assert!( + !get_auth_file(codex_home.path()).exists(), + "env auth should not write auth.json" + ); + server.verify().await; +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn personal_access_token_does_not_offer_unauthorized_recovery() { + let codex_home = tempdir().unwrap(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/user-auth-credential/whoami")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(personal_access_token_whoami(WORKSPACE_ID_ALLOWED)), + ) + .expect(1) + .mount(&server) + .await; + let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); + let _access_token_guard = + EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-no-unauthorized-recovery"); + let manager = Arc::new( + AuthManager::new( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + + let recovery = manager.unauthorized_recovery(); + + assert!(!recovery.has_next()); + assert_eq!(recovery.unavailable_reason(), "not_refreshable_auth"); + manager + .refresh_token_from_authority() + .await + .expect("personal access tokens do not use OAuth refresh"); + server.verify().await; +} + #[tokio::test] #[serial(codex_auth_env)] async fn load_auth_keeps_codex_api_key_env_precedence() { @@ -938,6 +1111,7 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat tokens: None, last_refresh: None, agent_identity: Some(agent_identity), + personal_access_token: None, }, AuthCredentialsStoreMode::File, ) @@ -1091,6 +1265,16 @@ fn test_jwks_body() -> serde_json::Value { }) } +fn personal_access_token_whoami(account_id: &str) -> serde_json::Value { + json!({ + "email": "user@example.com", + "chatgpt_user_id": "user-123", + "chatgpt_account_id": account_id, + "chatgpt_plan_type": "business", + "chatgpt_account_is_fedramp": true, + }) +} + const TEST_AGENT_IDENTITY_RSA_PRIVATE_KEY_PEM: &[u8] = br#"-----BEGIN PRIVATE KEY----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDWpAXYypOsYAwO bvBduMk/mxaoYDze0AZSzaSzLuIlcsl2EKDgC3AabhIWXh/qTGEJLOU3VB1e5mO9 diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 22e2c9f3b..9e255a99a 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -24,9 +24,12 @@ use codex_app_server_protocol::AuthMode as ApiAuthMode; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ModelProviderAuthInfo; +use super::access_token::CodexAccessToken; +use super::access_token::classify_codex_access_token; use super::external_bearer::BearerTokenRefresher; use super::revoke::revoke_auth_tokens; pub use crate::auth::agent_identity::AgentIdentityAuth; +pub use crate::auth::personal_access_token::PersonalAccessTokenAuth; pub use crate::auth::storage::AgentIdentityAuthRecord; pub use crate::auth::storage::AuthDotJson; use crate::auth::storage::AuthStorageBackend; @@ -53,11 +56,15 @@ pub enum CodexAuth { Chatgpt(ChatgptAuth), ChatgptAuthTokens(ChatgptAuthTokens), AgentIdentity(AgentIdentityAuth), + PersonalAccessToken(PersonalAccessTokenAuth), } impl PartialEq for CodexAuth { fn eq(&self, other: &Self) -> bool { - self.api_auth_mode() == other.api_auth_mode() + match (self, other) { + (Self::PersonalAccessToken(a), Self::PersonalAccessToken(b)) => a == b, + _ => self.api_auth_mode() == other.api_auth_mode(), + } } } @@ -220,6 +227,14 @@ impl CodexAuth { }; return Self::from_agent_identity_jwt(&agent_identity, chatgpt_base_url).await; } + if auth_mode == ApiAuthMode::PersonalAccessToken { + let Some(personal_access_token) = auth_dot_json.personal_access_token.as_deref() else { + return Err(std::io::Error::other( + "personal access token auth is missing a personal access token.", + )); + }; + return Self::from_personal_access_token(personal_access_token).await; + } let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode); let state = ChatgptAuthState { @@ -237,6 +252,9 @@ impl CodexAuth { } ApiAuthMode::ApiKey => unreachable!("api key mode is handled above"), ApiAuthMode::AgentIdentity => unreachable!("agent identity mode is handled above"), + ApiAuthMode::PersonalAccessToken => { + unreachable!("personal access token mode is handled above") + } } } @@ -266,11 +284,18 @@ impl CodexAuth { Ok(Self::AgentIdentity(AgentIdentityAuth::load(record).await?)) } + pub async fn from_personal_access_token(access_token: &str) -> std::io::Result { + Ok(Self::PersonalAccessToken( + PersonalAccessTokenAuth::load(access_token).await?, + )) + } + pub fn auth_mode(&self) -> AuthMode { match self { Self::ApiKey(_) => AuthMode::ApiKey, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => AuthMode::Chatgpt, Self::AgentIdentity(_) => AuthMode::AgentIdentity, + Self::PersonalAccessToken(_) => AuthMode::PersonalAccessToken, } } @@ -280,6 +305,7 @@ impl CodexAuth { Self::Chatgpt(_) => ApiAuthMode::Chatgpt, Self::ChatgptAuthTokens(_) => ApiAuthMode::ChatgptAuthTokens, Self::AgentIdentity(_) => ApiAuthMode::AgentIdentity, + Self::PersonalAccessToken(_) => ApiAuthMode::PersonalAccessToken, } } @@ -287,14 +313,21 @@ impl CodexAuth { self.auth_mode() == AuthMode::ApiKey } + pub fn is_personal_access_token_auth(&self) -> bool { + self.auth_mode() == AuthMode::PersonalAccessToken + } + pub fn is_chatgpt_auth(&self) -> bool { - matches!(self, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_)) + self.api_auth_mode().has_chatgpt_account() } pub fn uses_codex_backend(&self) -> bool { matches!( self, - Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) | Self::AgentIdentity(_) + Self::Chatgpt(_) + | Self::ChatgptAuthTokens(_) + | Self::AgentIdentity(_) + | Self::PersonalAccessToken(_) ) } @@ -302,11 +335,18 @@ impl CodexAuth { matches!(self, Self::ChatgptAuthTokens(_)) } + fn supports_unauthorized_recovery(&self) -> bool { + matches!(self, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_)) + } + /// Returns `None` if `auth_mode() != AuthMode::ApiKey`. pub fn api_key(&self) -> Option<&str> { match self { Self::ApiKey(auth) => Some(auth.api_key.as_str()), - Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) | Self::AgentIdentity(_) => None, + Self::Chatgpt(_) + | Self::ChatgptAuthTokens(_) + | Self::AgentIdentity(_) + | Self::PersonalAccessToken(_) => None, } } @@ -334,6 +374,7 @@ impl CodexAuth { Self::AgentIdentity(_) => Err(std::io::Error::other( "agent identity auth does not expose a bearer token", )), + Self::PersonalAccessToken(auth) => Ok(auth.access_token().to_string()), } } @@ -341,6 +382,7 @@ impl CodexAuth { pub fn get_account_id(&self) -> Option { match self { Self::AgentIdentity(auth) => Some(auth.account_id().to_string()), + Self::PersonalAccessToken(auth) => Some(auth.account_id().to_string()), _ => self.get_current_token_data().and_then(|t| t.account_id), } } @@ -349,6 +391,7 @@ impl CodexAuth { pub fn is_fedramp_account(&self) -> bool { match self { Self::AgentIdentity(auth) => auth.is_fedramp_account(), + Self::PersonalAccessToken(auth) => auth.is_fedramp_account(), _ => self .get_current_token_data() .is_some_and(|t| t.id_token.is_fedramp_account()), @@ -359,6 +402,7 @@ impl CodexAuth { pub fn get_account_email(&self) -> Option { match self { Self::AgentIdentity(auth) => Some(auth.email().to_string()), + Self::PersonalAccessToken(auth) => Some(auth.email().to_string()), _ => self.get_current_token_data().and_then(|t| t.id_token.email), } } @@ -367,6 +411,7 @@ impl CodexAuth { pub fn get_chatgpt_user_id(&self) -> Option { match self { Self::AgentIdentity(auth) => Some(auth.chatgpt_user_id().to_string()), + Self::PersonalAccessToken(auth) => Some(auth.chatgpt_user_id().to_string()), _ => self .get_current_token_data() .and_then(|t| t.id_token.chatgpt_user_id), @@ -380,6 +425,9 @@ impl CodexAuth { if let Self::AgentIdentity(auth) = self { return Some(auth.plan_type()); } + if let Self::PersonalAccessToken(auth) = self { + return Some(auth.plan_type()); + } self.get_current_token_data().map(|t| { t.id_token @@ -399,7 +447,7 @@ impl CodexAuth { let state = match self { Self::Chatgpt(auth) => &auth.state, Self::ChatgptAuthTokens(auth) => &auth.state, - Self::ApiKey(_) | Self::AgentIdentity(_) => return None, + Self::ApiKey(_) | Self::AgentIdentity(_) | Self::PersonalAccessToken(_) => return None, }; #[expect(clippy::unwrap_used)] state.auth_dot_json.lock().unwrap().clone() @@ -423,6 +471,7 @@ impl CodexAuth { }), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; let client = create_client(); @@ -539,6 +588,7 @@ pub fn login_with_api_key( tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) } @@ -550,17 +600,35 @@ pub async fn login_with_access_token( auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option<&str>, ) -> std::io::Result<()> { - let base_url = chatgpt_base_url - .unwrap_or(DEFAULT_CHATGPT_BACKEND_BASE_URL) - .trim_end_matches('/') - .to_string(); - verified_agent_identity_record(access_token, &base_url).await?; - let auth_dot_json = AuthDotJson { - auth_mode: Some(ApiAuthMode::AgentIdentity), - openai_api_key: None, - tokens: None, - last_refresh: None, - agent_identity: Some(access_token.to_string()), + let auth_dot_json = match classify_codex_access_token(access_token) { + CodexAccessToken::PersonalAccessToken(access_token) => { + PersonalAccessTokenAuth::load(access_token).await?; + AuthDotJson { + // Infer PAT auth from the credential field so older Codex builds can still + // deserialize auth.json after a rollback. + auth_mode: None, + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: None, + personal_access_token: Some(access_token.to_string()), + } + } + CodexAccessToken::AgentIdentityJwt(jwt) => { + let base_url = chatgpt_base_url + .unwrap_or(DEFAULT_CHATGPT_BACKEND_BASE_URL) + .trim_end_matches('/') + .to_string(); + verified_agent_identity_record(jwt, &base_url).await?; + AuthDotJson { + auth_mode: Some(ApiAuthMode::AgentIdentity), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: Some(jwt.to_string()), + personal_access_token: None, + } + } }; save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) } @@ -633,10 +701,12 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< (ForcedLoginMethod::Api, AuthMode::ApiKey) => None, (ForcedLoginMethod::Chatgpt, AuthMode::Chatgpt) | (ForcedLoginMethod::Chatgpt, AuthMode::ChatgptAuthTokens) - | (ForcedLoginMethod::Chatgpt, AuthMode::AgentIdentity) => None, + | (ForcedLoginMethod::Chatgpt, AuthMode::AgentIdentity) + | (ForcedLoginMethod::Chatgpt, AuthMode::PersonalAccessToken) => None, (ForcedLoginMethod::Api, AuthMode::Chatgpt) | (ForcedLoginMethod::Api, AuthMode::ChatgptAuthTokens) - | (ForcedLoginMethod::Api, AuthMode::AgentIdentity) => Some( + | (ForcedLoginMethod::Api, AuthMode::AgentIdentity) + | (ForcedLoginMethod::Api, AuthMode::PersonalAccessToken) => Some( "API key login is required, but ChatGPT is currently being used. Logging out." .to_string(), ), @@ -657,7 +727,7 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< if let Some(expected_account_ids) = config.forced_chatgpt_workspace_id.as_deref() { let chatgpt_account_id = match &auth { - CodexAuth::ApiKey(_) => return Ok(()), + CodexAuth::ApiKey(_) | CodexAuth::PersonalAccessToken(_) => return Ok(()), CodexAuth::AgentIdentity(_) => auth.get_account_id(), CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => { let token_data = match auth.get_token_data() { @@ -758,17 +828,26 @@ async fn load_auth( return Ok(Some(auth)); } + if let Some(access_token) = read_codex_access_token_from_env() { + return match classify_codex_access_token(&access_token) { + CodexAccessToken::PersonalAccessToken(access_token) => { + CodexAuth::from_personal_access_token(access_token) + .await + .map(Some) + } + CodexAccessToken::AgentIdentityJwt(jwt) => { + CodexAuth::from_agent_identity_jwt(jwt, chatgpt_base_url) + .await + .map(Some) + } + }; + } + // If the caller explicitly requested ephemeral auth, there is no persisted fallback. if auth_credentials_store_mode == AuthCredentialsStoreMode::Ephemeral { return Ok(None); } - if let Some(agent_identity) = read_codex_access_token_from_env() { - return CodexAuth::from_agent_identity_jwt(&agent_identity, chatgpt_base_url) - .await - .map(Some); - } - // Fall back to the configured persistent store (file/keyring/auto) for managed auth. let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); let auth_dot_json = match storage.load()? { @@ -963,6 +1042,7 @@ impl AuthDotJson { tokens: Some(tokens), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }) } @@ -983,6 +1063,9 @@ impl AuthDotJson { if let Some(mode) = self.auth_mode { return mode; } + if self.personal_access_token.is_some() { + return ApiAuthMode::PersonalAccessToken; + } if self.openai_api_key.is_some() { return ApiAuthMode::ApiKey; } @@ -1126,7 +1209,7 @@ impl UnauthorizedRecovery { .manager .auth_cached() .as_ref() - .is_some_and(CodexAuth::is_chatgpt_auth) + .is_some_and(CodexAuth::supports_unauthorized_recovery) { return false; } @@ -1147,11 +1230,20 @@ impl UnauthorizedRecovery { }; } + if self + .manager + .auth_cached() + .as_ref() + .is_some_and(CodexAuth::is_personal_access_token_auth) + { + return "not_refreshable_auth"; + } + if !self .manager .auth_cached() .as_ref() - .is_some_and(CodexAuth::is_chatgpt_auth) + .is_some_and(CodexAuth::supports_unauthorized_recovery) { return "not_chatgpt_auth"; } @@ -1497,6 +1589,7 @@ impl AuthManager { } _ => false, }, + (ApiAuthMode::PersonalAccessToken, ApiAuthMode::PersonalAccessToken) => a == b, _ => false, }, _ => false, @@ -1690,7 +1783,7 @@ impl AuthManager { let auth_before_reload = self.auth_cached(); if auth_before_reload .as_ref() - .is_some_and(CodexAuth::is_api_key_auth) + .is_some_and(|auth| auth.is_api_key_auth() || auth.is_personal_access_token_auth()) { return Ok(()); } @@ -1756,7 +1849,9 @@ impl AuthManager { self.refresh_and_persist_chatgpt_token(&chatgpt_auth, token_data.refresh_token) .await } - CodexAuth::ApiKey(_) | CodexAuth::AgentIdentity(_) => Ok(()), + CodexAuth::ApiKey(_) + | CodexAuth::AgentIdentity(_) + | CodexAuth::PersonalAccessToken(_) => Ok(()), }; if let Err(RefreshTokenError::Permanent(error)) = &result { self.record_permanent_refresh_failure_if_unchanged(&attempted_auth, error); @@ -1805,7 +1900,12 @@ impl AuthManager { pub fn current_auth_uses_codex_backend(&self) -> bool { matches!( self.auth_mode(), - Some(AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens | AuthMode::AgentIdentity) + Some( + AuthMode::Chatgpt + | AuthMode::ChatgptAuthTokens + | AuthMode::AgentIdentity + | AuthMode::PersonalAccessToken + ) ) } diff --git a/codex-rs/login/src/auth/mod.rs b/codex-rs/login/src/auth/mod.rs index 167ccd3f7..9bf6c350f 100644 --- a/codex-rs/login/src/auth/mod.rs +++ b/codex-rs/login/src/auth/mod.rs @@ -1,6 +1,8 @@ +mod access_token; mod agent_identity; pub mod default_client; pub mod error; +mod personal_access_token; mod storage; mod util; diff --git a/codex-rs/login/src/auth/personal_access_token.rs b/codex-rs/login/src/auth/personal_access_token.rs new file mode 100644 index 000000000..b99092f51 --- /dev/null +++ b/codex-rs/login/src/auth/personal_access_token.rs @@ -0,0 +1,112 @@ +use codex_client::CodexHttpClient; +use codex_protocol::account::PlanType as AccountPlanType; +use codex_protocol::auth::PlanType as InternalPlanType; +use serde::Deserialize; +use std::env; +use std::fmt; + +use crate::default_client::create_client; + +const PROD_AUTHAPI_BASE_URL: &str = "https://auth.openai.com/api/accounts"; +const CODEX_AUTHAPI_BASE_URL_ENV_VAR: &str = "CODEX_AUTHAPI_BASE_URL"; +const WHOAMI_PATH: &str = "/v1/user-auth-credential/whoami"; + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +struct PersonalAccessTokenMetadata { + email: String, + chatgpt_user_id: String, + chatgpt_account_id: String, + chatgpt_plan_type: String, + chatgpt_account_is_fedramp: bool, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct PersonalAccessTokenAuth { + access_token: String, + metadata: PersonalAccessTokenMetadata, +} + +impl fmt::Debug for PersonalAccessTokenAuth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PersonalAccessTokenAuth") + .field("access_token", &"") + .field("metadata", &self.metadata) + .finish() + } +} + +impl PersonalAccessTokenAuth { + pub(super) async fn load(access_token: &str) -> std::io::Result { + let authapi_base_url = env::var(CODEX_AUTHAPI_BASE_URL_ENV_VAR) + .ok() + .map(|base_url| base_url.trim().trim_end_matches('/').to_string()) + .filter(|base_url| !base_url.is_empty()) + .unwrap_or_else(|| PROD_AUTHAPI_BASE_URL.to_string()); + hydrate_personal_access_token(&create_client(), &authapi_base_url, access_token).await + } + + pub fn access_token(&self) -> &str { + &self.access_token + } + + pub fn account_id(&self) -> &str { + &self.metadata.chatgpt_account_id + } + + pub fn chatgpt_user_id(&self) -> &str { + &self.metadata.chatgpt_user_id + } + + pub fn email(&self) -> &str { + &self.metadata.email + } + + pub fn plan_type(&self) -> AccountPlanType { + InternalPlanType::from_raw_value(&self.metadata.chatgpt_plan_type).into() + } + + pub fn is_fedramp_account(&self) -> bool { + self.metadata.chatgpt_account_is_fedramp + } +} + +async fn hydrate_personal_access_token( + client: &CodexHttpClient, + authapi_base_url: &str, + access_token: &str, +) -> std::io::Result { + let endpoint = format!("{}{WHOAMI_PATH}", authapi_base_url.trim_end_matches('/')); + let response = client + .get(&endpoint) + .bearer_auth(access_token) + .send() + .await + .map_err(|err| { + std::io::Error::other(format!( + "failed to request personal access token metadata: {err}" + )) + })?; + if !response.status().is_success() { + return Err(std::io::Error::other(format!( + "personal access token metadata request failed with status {}", + response.status() + ))); + } + + let metadata = response + .json::() + .await + .map_err(|err| { + std::io::Error::other(format!( + "failed to decode personal access token metadata: {err}" + )) + })?; + Ok(PersonalAccessTokenAuth { + access_token: access_token.to_string(), + metadata, + }) +} + +#[cfg(test)] +#[path = "personal_access_token_tests.rs"] +mod tests; diff --git a/codex-rs/login/src/auth/personal_access_token_tests.rs b/codex-rs/login/src/auth/personal_access_token_tests.rs new file mode 100644 index 000000000..ac6ee1226 --- /dev/null +++ b/codex-rs/login/src/auth/personal_access_token_tests.rs @@ -0,0 +1,71 @@ +use super::*; +use pretty_assertions::assert_eq; +use serde_json::json; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +fn response(email: Option<&str>) -> serde_json::Value { + json!({ + "email": email, + "chatgpt_user_id": "user-123", + "chatgpt_account_id": "account-123", + "chatgpt_plan_type": "enterprise", + "chatgpt_account_is_fedramp": true, + }) +} + +#[tokio::test] +async fn hydrate_sends_bearer_token_and_preserves_metadata() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(WHOAMI_PATH)) + .and(header("authorization", "Bearer at-example")) + .respond_with(ResponseTemplate::new(200).set_body_json(response(Some("user@example.com")))) + .expect(1) + .mount(&server) + .await; + + let auth = hydrate_personal_access_token(&create_client(), &server.uri(), "at-example") + .await + .expect("personal access token hydration should succeed"); + + assert_eq!( + auth, + PersonalAccessTokenAuth { + access_token: "at-example".to_string(), + metadata: PersonalAccessTokenMetadata { + email: "user@example.com".to_string(), + chatgpt_user_id: "user-123".to_string(), + chatgpt_account_id: "account-123".to_string(), + chatgpt_plan_type: "enterprise".to_string(), + chatgpt_account_is_fedramp: true, + }, + } + ); + server.verify().await; +} + +#[tokio::test] +async fn hydrate_rejects_missing_email() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(WHOAMI_PATH)) + .respond_with(ResponseTemplate::new(200).set_body_json(response(/*email*/ None))) + .expect(1) + .mount(&server) + .await; + + let err = hydrate_personal_access_token(&create_client(), &server.uri(), "at-example") + .await + .expect_err("personal access token hydration should reject missing email"); + + assert!( + err.to_string() + .contains("failed to decode personal access token metadata") + ); + server.verify().await; +} diff --git a/codex-rs/login/src/auth/storage.rs b/codex-rs/login/src/auth/storage.rs index 3a1c8ae6a..d0e606a8c 100644 --- a/codex-rs/login/src/auth/storage.rs +++ b/codex-rs/login/src/auth/storage.rs @@ -45,6 +45,9 @@ pub struct AuthDotJson { #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_identity: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub personal_access_token: Option, } #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)] diff --git a/codex-rs/login/src/auth/storage_tests.rs b/codex-rs/login/src/auth/storage_tests.rs index b5646ef53..debe0e18d 100644 --- a/codex-rs/login/src/auth/storage_tests.rs +++ b/codex-rs/login/src/auth/storage_tests.rs @@ -19,6 +19,7 @@ async fn file_storage_load_returns_auth_dot_json() -> anyhow::Result<()> { tokens: None, last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; storage @@ -40,6 +41,7 @@ async fn file_storage_save_persists_auth_dot_json() -> anyhow::Result<()> { tokens: None, last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; let file = get_auth_file(codex_home.path()); @@ -73,6 +75,27 @@ async fn file_storage_round_trips_agent_identity_auth() -> anyhow::Result<()> { tokens: None, last_refresh: None, agent_identity: Some(agent_identity), + personal_access_token: None, + }; + + storage.save(&auth_dot_json)?; + + let loaded = storage.load()?; + assert_eq!(Some(auth_dot_json), loaded); + Ok(()) +} + +#[tokio::test] +async fn file_storage_round_trips_personal_access_token_auth() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let auth_dot_json = AuthDotJson { + auth_mode: Some(AuthMode::PersonalAccessToken), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: None, + personal_access_token: Some("at-example".to_string()), }; storage.save(&auth_dot_json)?; @@ -122,6 +145,7 @@ fn file_storage_delete_removes_auth_file() -> anyhow::Result<()> { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; let storage = create_auth_storage(dir.path().to_path_buf(), AuthCredentialsStoreMode::File); storage.save(&auth_dot_json)?; @@ -146,6 +170,7 @@ fn ephemeral_storage_save_load_delete_is_in_memory_only() -> anyhow::Result<()> tokens: None, last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; storage.save(&auth_dot_json)?; @@ -245,6 +270,7 @@ fn auth_with_prefix(prefix: &str) -> AuthDotJson { }), last_refresh: None, agent_identity: None, + personal_access_token: None, } } @@ -270,6 +296,7 @@ fn keyring_auth_storage_load_returns_deserialized_auth() -> anyhow::Result<()> { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; seed_keyring_with_auth( &mock_keyring, @@ -313,6 +340,7 @@ fn keyring_auth_storage_save_persists_and_removes_fallback_file() -> anyhow::Res }), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; storage.save(&auth)?; diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index b72bc946f..5a1833b58 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -822,6 +822,7 @@ pub(crate) async fn persist_tokens_async( tokens: Some(tokens), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; save_auth(&codex_home, &auth, auth_credentials_store_mode)?; Ok::<_, io::Error>((previous_auth, auth)) @@ -940,6 +941,20 @@ pub(crate) fn ensure_workspace_allowed( return Err("Login is restricted to a specific workspace, but the token did not include an chatgpt_account_id claim.".to_string()); }; + ensure_workspace_account_allowed(Some(expected), actual) +} + +/// Validates an already known ChatGPT account ID against an optional workspace restriction. +/// +/// PAT login calls this directly because `/whoami` supplies the account ID without an ID token. +pub(crate) fn ensure_workspace_account_allowed( + expected: Option<&[String]>, + actual: &str, +) -> Result<(), String> { + let Some(expected) = expected else { + return Ok(()); + }; + if expected.iter().any(|workspace_id| workspace_id == actual) { Ok(()) } else { @@ -1311,6 +1326,7 @@ mod tests { }), last_refresh: None, agent_identity: None, + personal_access_token: None, } } diff --git a/codex-rs/login/tests/suite/auth_refresh.rs b/codex-rs/login/tests/suite/auth_refresh.rs index 83897a0d3..b30d738cd 100644 --- a/codex-rs/login/tests/suite/auth_refresh.rs +++ b/codex-rs/login/tests/suite/auth_refresh.rs @@ -55,6 +55,7 @@ async fn refresh_token_succeeds_updates_storage() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -119,6 +120,7 @@ async fn refresh_token_refreshes_when_auth_is_unchanged() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -184,6 +186,7 @@ async fn auth_refreshes_when_access_token_is_near_expiry() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -234,6 +237,7 @@ async fn auth_skips_access_token_outside_refresh_window() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -270,6 +274,7 @@ async fn refresh_token_skips_refresh_when_auth_changed() -> Result<()> { tokens: Some(initial_tokens), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -280,6 +285,7 @@ async fn refresh_token_skips_refresh_when_auth_changed() -> Result<()> { tokens: Some(disk_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -335,6 +341,7 @@ async fn refresh_token_errors_on_account_mismatch() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -346,6 +353,7 @@ async fn refresh_token_errors_on_account_mismatch() -> Result<()> { tokens: Some(disk_tokens), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -405,6 +413,7 @@ async fn returns_fresh_tokens_as_is() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(stale_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -453,6 +462,7 @@ async fn refreshes_token_when_access_token_is_expired() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(fresh_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -503,6 +513,7 @@ async fn auth_reloads_disk_auth_when_cached_auth_is_stale() -> Result<()> { tokens: Some(initial_tokens), last_refresh: Some(stale_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -514,6 +525,7 @@ async fn auth_reloads_disk_auth_when_cached_auth_is_stale() -> Result<()> { tokens: Some(disk_tokens.clone()), last_refresh: Some(fresh_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -566,6 +578,7 @@ async fn auth_reloads_disk_auth_without_calling_expired_refresh_token() -> Resul tokens: Some(initial_tokens), last_refresh: Some(stale_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -577,6 +590,7 @@ async fn auth_reloads_disk_auth_without_calling_expired_refresh_token() -> Resul tokens: Some(disk_tokens.clone()), last_refresh: Some(fresh_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -627,6 +641,7 @@ async fn refresh_token_returns_permanent_error_for_expired_refresh_token() -> Re tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -680,6 +695,7 @@ async fn refresh_token_does_not_retry_after_permanent_failure() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -747,6 +763,7 @@ async fn refresh_token_does_not_retry_after_bad_request_reused_failure() -> Resu tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -814,6 +831,7 @@ async fn refresh_token_reloads_changed_auth_after_permanent_failure() -> Result< tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -836,6 +854,7 @@ async fn refresh_token_reloads_changed_auth_after_permanent_failure() -> Result< tokens: Some(disk_tokens.clone()), last_refresh: Some(fresh_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -895,6 +914,7 @@ async fn refresh_token_returns_transient_error_on_server_failure() -> Result<()> tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -948,6 +968,7 @@ async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -958,6 +979,7 @@ async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> { tokens: Some(disk_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -1042,6 +1064,7 @@ async fn unauthorized_recovery_errors_on_account_mismatch() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -1053,6 +1076,7 @@ async fn unauthorized_recovery_errors_on_account_mismatch() -> Result<()> { tokens: Some(disk_tokens), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -1111,6 +1135,7 @@ async fn unauthorized_recovery_requires_chatgpt_auth() -> Result<()> { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&auth).await?; diff --git a/codex-rs/login/tests/suite/logout.rs b/codex-rs/login/tests/suite/logout.rs index 2364ee7f5..2dd1bef83 100644 --- a/codex-rs/login/tests/suite/logout.rs +++ b/codex-rs/login/tests/suite/logout.rs @@ -195,6 +195,7 @@ fn chatgpt_auth_with_refresh_token(refresh_token: &str) -> AuthDotJson { }), last_refresh: None, agent_identity: None, + personal_access_token: None, } } diff --git a/codex-rs/model-provider-info/src/lib.rs b/codex-rs/model-provider-info/src/lib.rs index c20a51a3a..5f0f84050 100644 --- a/codex-rs/model-provider-info/src/lib.rs +++ b/codex-rs/model-provider-info/src/lib.rs @@ -237,7 +237,12 @@ impl ModelProviderInfo { pub fn to_api_provider(&self, auth_mode: Option) -> CodexResult { let default_base_url = if matches!( auth_mode, - Some(AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens | AuthMode::AgentIdentity) + Some( + AuthMode::Chatgpt + | AuthMode::ChatgptAuthTokens + | AuthMode::AgentIdentity + | AuthMode::PersonalAccessToken + ) ) { CHATGPT_CODEX_BASE_URL } else { diff --git a/codex-rs/model-provider-info/src/model_provider_info_tests.rs b/codex-rs/model-provider-info/src/model_provider_info_tests.rs index abfa40a36..a303050dc 100644 --- a/codex-rs/model-provider-info/src/model_provider_info_tests.rs +++ b/codex-rs/model-provider-info/src/model_provider_info_tests.rs @@ -139,6 +139,15 @@ fn test_supports_remote_compaction_for_openai() { assert!(provider.supports_remote_compaction()); } +#[test] +fn test_personal_access_token_uses_chatgpt_codex_base_url() { + let api_provider = ModelProviderInfo::create_openai_provider(/*base_url*/ None) + .to_api_provider(Some(AuthMode::PersonalAccessToken)) + .expect("OpenAI provider should build API provider"); + + assert_eq!(api_provider.base_url, CHATGPT_CODEX_BASE_URL); +} + #[test] fn test_supports_remote_compaction_for_azure_name() { let provider = ModelProviderInfo { diff --git a/codex-rs/model-provider/src/auth.rs b/codex-rs/model-provider/src/auth.rs index 3c7f4dbd0..4f87d46ba 100644 --- a/codex-rs/model-provider/src/auth.rs +++ b/codex-rs/model-provider/src/auth.rs @@ -109,13 +109,14 @@ pub fn auth_provider_from_auth(auth: &CodexAuth) -> SharedAuthProvider { CodexAuth::AgentIdentity(auth) => { Arc::new(AgentIdentityAuthProvider { auth: auth.clone() }) } - CodexAuth::ApiKey(_) | CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => { - Arc::new(BearerAuthProvider { - token: auth.get_token().ok(), - account_id: auth.get_account_id(), - is_fedramp_account: auth.is_fedramp_account(), - }) - } + CodexAuth::ApiKey(_) + | CodexAuth::Chatgpt(_) + | CodexAuth::ChatgptAuthTokens(_) + | CodexAuth::PersonalAccessToken(_) => Arc::new(BearerAuthProvider { + token: auth.get_token().ok(), + account_id: auth.get_account_id(), + is_fedramp_account: auth.is_fedramp_account(), + }), } } diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index e2ef3f365..afd07065d 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -212,7 +212,8 @@ impl ModelProvider for ConfiguredModelProvider { CodexAuth::ApiKey(_) => Ok(ProviderAccount::ApiKey), CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) - | CodexAuth::AgentIdentity(_) => { + | CodexAuth::AgentIdentity(_) + | CodexAuth::PersonalAccessToken(_) => { let email = auth.get_account_email(); let plan_type = auth.account_plan_type(); @@ -453,6 +454,21 @@ mod tests { ); } + #[test] + fn openai_provider_rejects_chatgpt_account_state_without_email() { + let provider = create_model_provider( + ModelProviderInfo::create_openai_provider(/*base_url*/ None), + Some(AuthManager::from_auth_for_testing( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + )), + ); + + assert_eq!( + provider.account_state(), + Err(ProviderAccountError::MissingChatgptAccountDetails) + ); + } + #[test] fn custom_non_openai_provider_returns_no_account_state() { let provider = create_model_provider( diff --git a/codex-rs/models-manager/src/manager.rs b/codex-rs/models-manager/src/manager.rs index ac4044ac2..b21911a4d 100644 --- a/codex-rs/models-manager/src/manager.rs +++ b/codex-rs/models-manager/src/manager.rs @@ -328,10 +328,9 @@ impl OpenAiModelsManager { .iter() .any(|model| model.visibility == ModelVisibility::List) && self.auth_manager.as_ref().is_some_and(|auth_manager| { - matches!( - auth_manager.auth_mode(), - Some(AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens) - ) + auth_manager + .auth_mode() + .is_some_and(AuthMode::has_chatgpt_account) }); if should_use_remote_models_only { *self.remote_models.write().await = models; diff --git a/codex-rs/models-manager/src/manager_tests.rs b/codex-rs/models-manager/src/manager_tests.rs index ede7cfe79..2e39ef316 100644 --- a/codex-rs/models-manager/src/manager_tests.rs +++ b/codex-rs/models-manager/src/manager_tests.rs @@ -211,6 +211,7 @@ c2ln", }), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; std::fs::create_dir_all(codex_home).expect("codex home should be created"); std::fs::write( diff --git a/codex-rs/otel/src/lib.rs b/codex-rs/otel/src/lib.rs index 1a689d368..586dfa15a 100644 --- a/codex-rs/otel/src/lib.rs +++ b/codex-rs/otel/src/lib.rs @@ -57,7 +57,8 @@ impl From for TelemetryAuthMode { codex_app_server_protocol::AuthMode::ApiKey => Self::ApiKey, codex_app_server_protocol::AuthMode::Chatgpt | codex_app_server_protocol::AuthMode::ChatgptAuthTokens - | codex_app_server_protocol::AuthMode::AgentIdentity => Self::Chatgpt, + | codex_app_server_protocol::AuthMode::AgentIdentity + | codex_app_server_protocol::AuthMode::PersonalAccessToken => Self::Chatgpt, } } } diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 4aeba59aa..dea630876 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -87,10 +87,9 @@ impl App { notification.plan_type, ), notification.plan_type, - matches!( - notification.auth_mode, - Some(AuthMode::Chatgpt) | Some(AuthMode::ChatgptAuthTokens) - ), + notification + .auth_mode + .is_some_and(AuthMode::has_chatgpt_account), ); return; } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index f5836f4c2..32db3d2cf 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1192,7 +1192,8 @@ pub(crate) fn status_account_display_from_auth_mode( Some(AuthMode::ApiKey) => Some(StatusAccountDisplay::ApiKey), Some(AuthMode::Chatgpt) | Some(AuthMode::ChatgptAuthTokens) - | Some(AuthMode::AgentIdentity) => Some(StatusAccountDisplay::ChatGpt { + | Some(AuthMode::AgentIdentity) + | Some(AuthMode::PersonalAccessToken) => Some(StatusAccountDisplay::ChatGpt { email: None, plan: plan_type.map(plan_type_display_name), }), diff --git a/codex-rs/tui/src/local_chatgpt_auth.rs b/codex-rs/tui/src/local_chatgpt_auth.rs index 270d64f5f..d7ca01f61 100644 --- a/codex-rs/tui/src/local_chatgpt_auth.rs +++ b/codex-rs/tui/src/local_chatgpt_auth.rs @@ -109,6 +109,7 @@ mod tests { }), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; save_auth(codex_home, &auth, AuthCredentialsStoreMode::File) .expect("chatgpt auth should save"); @@ -156,6 +157,7 @@ mod tests { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }, AuthCredentialsStoreMode::File, ) diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 0d8a1fa77..e10700ed8 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -10,6 +10,7 @@ use codex_app_server_client::AppServerRequestHandle; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; +#[cfg(test)] use codex_app_server_protocol::AuthMode as AppServerAuthMode; use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::ClientRequest; @@ -845,8 +846,7 @@ impl AuthModeWidget { fn handle_existing_chatgpt_login(&mut self) -> bool { if matches!( self.login_status, - LoginStatus::AuthMode(AppServerAuthMode::Chatgpt) - | LoginStatus::AuthMode(AppServerAuthMode::ChatgptAuthTokens) + LoginStatus::AuthMode(auth_mode) if auth_mode.has_chatgpt_account() ) { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; self.request_frame.schedule_frame(); @@ -1106,17 +1106,22 @@ mod tests { } #[tokio::test] - async fn existing_chatgpt_auth_tokens_login_counts_as_signed_in() { - let (mut widget, _tmp) = widget_forced_chatgpt().await; - widget.login_status = LoginStatus::AuthMode(AppServerAuthMode::ChatgptAuthTokens); + async fn existing_non_oauth_chatgpt_login_counts_as_signed_in() { + for auth_mode in [ + AppServerAuthMode::ChatgptAuthTokens, + AppServerAuthMode::PersonalAccessToken, + ] { + let (mut widget, _tmp) = widget_forced_chatgpt().await; + widget.login_status = LoginStatus::AuthMode(auth_mode); - let handled = widget.handle_existing_chatgpt_login(); + let handled = widget.handle_existing_chatgpt_login(); - assert_eq!(handled, true); - assert!(matches!( - &*widget.sign_in_state.read().unwrap(), - SignInState::ChatGptSuccess - )); + assert_eq!(handled, true); + assert!(matches!( + &*widget.sign_in_state.read().unwrap(), + SignInState::ChatGptSuccess + )); + } } #[tokio::test]