From 373183c9a5f918c67ca565d018e0aa5d05a605ea Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 30 Jan 2026 09:48:33 -0800 Subject: [PATCH] feat: codex app-server --default-chatgpt-proxy-auth --- codex-rs/Cargo.lock | 2 +- .../src/protocol/common.rs | 30 +++- .../app-server-protocol/src/protocol/v2.rs | 17 ++ codex-rs/app-server/Cargo.toml | 1 + .../app-server/src/codex_message_processor.rs | 148 +++++++++++++----- codex-rs/app-server/src/lib.rs | 2 + codex-rs/app-server/src/main.rs | 16 +- codex-rs/app-server/src/message_processor.rs | 22 +++ .../app-server/tests/common/auth_fixtures.rs | 1 + codex-rs/backend-client/src/client.rs | 8 +- codex-rs/cli/src/main.rs | 18 +++ codex-rs/cloud-requirements/Cargo.toml | 1 - codex-rs/cloud-requirements/src/lib.rs | 5 +- codex-rs/core/src/api_bridge.rs | 4 +- codex-rs/core/src/auth.rs | 113 +++++++++---- codex-rs/core/src/auth/storage.rs | 25 +++ codex-rs/core/src/mcp/mod.rs | 2 +- codex-rs/core/tests/suite/auth_refresh.rs | 10 ++ codex-rs/login/src/server.rs | 1 + codex-rs/tui/src/status/helpers.rs | 2 +- 20 files changed, 348 insertions(+), 80 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 44f35f78ae..b40fdb5a60 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1084,6 +1084,7 @@ dependencies = [ "axum", "base64", "chrono", + "clap", "codex-app-server-protocol", "codex-arg0", "codex-backend-client", @@ -1298,7 +1299,6 @@ version = "0.0.0" dependencies = [ "async-trait", "base64", - "codex-app-server-protocol", "codex-backend-client", "codex-core", "codex-otel", diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 67736374ec..3b8c4ea63d 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -910,10 +910,36 @@ mod tests { Ok(()) } + #[test] + fn serialize_account_login_chatgpt_proxy() -> Result<()> { + let request = ClientRequest::LoginAccount { + request_id: RequestId::Integer(6), + params: v2::LoginAccountParams::ChatgptProxy { + account_id: Some("acc-123".to_string()), + email: Some("user@example.com".to_string()), + plan_type: Some(PlanType::Pro), + }, + }; + assert_eq!( + json!({ + "method": "account/login/start", + "id": 6, + "params": { + "type": "chatgptProxy", + "accountId": "acc-123", + "email": "user@example.com", + "planType": "pro" + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_get_account() -> Result<()> { let request = ClientRequest::GetAccount { - request_id: RequestId::Integer(6), + request_id: RequestId::Integer(7), params: v2::GetAccountParams { refresh_token: false, }, @@ -921,7 +947,7 @@ mod tests { assert_eq!( json!({ "method": "account/read", - "id": 6, + "id": 7, "params": { "refreshToken": false } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 8bea094487..ff8029cdb0 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -835,6 +835,20 @@ pub enum LoginAccountParams { #[serde(rename = "chatgpt")] #[ts(rename = "chatgpt")] Chatgpt, + /// Tokenless ChatGPT auth via a trusted proxy. + /// + /// The proxy is expected to inject the Authorization header, so Codex + /// should not set a bearer token on outgoing requests. + #[serde(rename = "chatgptProxy", rename_all = "camelCase")] + #[ts(rename = "chatgptProxy", rename_all = "camelCase")] + ChatgptProxy { + /// ChatGPT workspace/account identifier. + account_id: Option, + /// Account email address. + email: Option, + /// Account plan type. + plan_type: Option, + }, /// [UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. /// The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have. #[serde(rename = "chatgptAuthTokens")] @@ -872,6 +886,9 @@ pub enum LoginAccountResponse { /// URL the client should open in a browser to initiate the OAuth flow. auth_url: String, }, + #[serde(rename = "chatgptProxy", rename_all = "camelCase")] + #[ts(rename = "chatgptProxy", rename_all = "camelCase")] + ChatgptProxy {}, #[serde(rename = "chatgptAuthTokens", rename_all = "camelCase")] #[ts(rename = "chatgptAuthTokens", rename_all = "camelCase")] ChatgptAuthTokens {}, diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 820edf59c2..417d40ba08 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -32,6 +32,7 @@ codex-rmcp-client = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-json-to-toml = { workspace = true } chrono = { workspace = true } +clap = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } mcp-types = { workspace = true } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index c02f3c7368..e09bbfdbd6 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -146,6 +146,7 @@ use codex_core::ThreadSortKey as CoreThreadSortKey; use codex_core::auth::CLIENT_ID; use codex_core::auth::login_with_api_key; use codex_core::auth::login_with_chatgpt_auth_tokens; +use codex_core::auth::login_with_chatgpt_proxy; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigService; @@ -616,6 +617,14 @@ impl CodexMessageProcessor { LoginAccountParams::Chatgpt => { self.login_chatgpt_v2(request_id).await; } + LoginAccountParams::ChatgptProxy { + account_id, + email, + plan_type, + } => { + self.login_chatgpt_proxy(request_id, account_id, email, plan_type) + .await; + } LoginAccountParams::ChatgptAuthTokens { id_token, access_token, @@ -629,7 +638,7 @@ impl CodexMessageProcessor { fn external_auth_active_error(&self) -> JSONRPCErrorError { JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, - message: "External auth is active. Use account/login/start (chatgptAuthTokens) to update it or account/logout to clear it." + message: "External auth is active. Use account/login/start (chatgptAuthTokens or chatgptProxy) to update it or account/logout to clear it." .to_string(), data: None, } @@ -1011,6 +1020,89 @@ impl CodexMessageProcessor { } } + async fn login_chatgpt_proxy( + &mut self, + request_id: RequestId, + account_id: Option, + email: Option, + plan_type: Option, + ) { + if matches!( + self.config.forced_login_method, + Some(ForcedLoginMethod::Api) + ) { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "ChatGPT proxy auth is disabled. Use API key login instead.".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + + // Cancel any active login attempt to avoid persisting managed auth state. + { + let mut guard = self.active_login.lock().await; + if let Some(active) = guard.take() { + drop(active); + } + } + + if let Some(expected_workspace) = self.config.forced_chatgpt_workspace_id.as_deref() + && account_id.as_deref() != Some(expected_workspace) + { + let actual_workspace = account_id.as_deref().unwrap_or(""); + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!( + "ChatGPT proxy auth must use workspace {expected_workspace}, but received {actual_workspace}." + ), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + + if let Err(err) = login_with_chatgpt_proxy( + &self.config.codex_home, + account_id.as_deref(), + email.as_deref(), + plan_type, + self.config.cli_auth_credentials_store_mode, + ) { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to set ChatGPT proxy auth: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + self.auth_manager.reload(); + + self.outgoing + .send_response(request_id, LoginAccountResponse::ChatgptProxy {}) + .await; + + let payload_login_completed = AccountLoginCompletedNotification { + login_id: None, + success: true, + error: None, + }; + self.outgoing + .send_server_notification(ServerNotification::AccountLoginCompleted( + payload_login_completed, + )) + .await; + + let payload_v2 = AccountUpdatedNotification { + auth_mode: self.auth_manager.get_auth_mode(), + }; + self.outgoing + .send_server_notification(ServerNotification::AccountUpdated(payload_v2)) + .await; + } + async fn login_chatgpt_auth_tokens( &mut self, request_id: RequestId, @@ -1198,19 +1290,16 @@ impl CodexMessageProcessor { match self.auth_manager.auth().await { Some(auth) => { let auth_mode = auth.api_auth_mode(); - let (reported_auth_method, token_opt) = 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), + let token_opt = match auth.bearer_token() { + Ok(Some(token)) if include_token && !token.is_empty() => Some(token), + Ok(_) => None, Err(err) => { - tracing::warn!("failed to get token for auth status: {err}"); - (None, None) + tracing::warn!("failed to get bearer token for auth status: {err}"); + None } }; GetAuthStatusResponse { - auth_method: reported_auth_method, + auth_method: Some(auth_mode), auth_token: token_opt, requires_openai_auth: Some(true), } @@ -1243,31 +1332,20 @@ impl CodexMessageProcessor { return; } - let account = match self.auth_manager.auth_cached() { - Some(auth) => Some(match auth { - CodexAuth::ApiKey(_) => Account::ApiKey {}, - CodexAuth::ChatGpt(_) | CodexAuth::ChatGptAuthTokens(_) => { - let email = auth.get_account_email(); - let plan_type = auth.account_plan_type(); - - match (email, plan_type) { - (Some(email), Some(plan_type)) => Account::Chatgpt { email, plan_type }, - _ => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: - "email and plan type are required for chatgpt authentication" - .to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - } - } - }), - None => None, - }; + let account = self.auth_manager.auth_cached().map(|auth| match auth { + CodexAuth::ApiKey(_) => Account::ApiKey {}, + CodexAuth::ChatGpt(_) + | CodexAuth::ChatGptAuthTokens(_) + | CodexAuth::ChatGptProxy(_) => { + let email = auth + .get_account_email() + .unwrap_or_else(|| "unknown".to_string()); + let plan_type = auth + .account_plan_type() + .unwrap_or(codex_protocol::account::PlanType::Unknown); + Account::Chatgpt { email, plan_type } + } + }); let response = GetAccountResponse { account, diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 5b3d39704c..221d185108 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -168,6 +168,7 @@ pub async fn run_main( cli_config_overrides: CliConfigOverrides, loader_overrides: LoaderOverrides, default_analytics_enabled: bool, + default_chatgpt_proxy_auth: bool, ) -> IoResult<()> { // Set up channels. let (incoming_tx, mut incoming_rx) = mpsc::channel::(CHANNEL_CAPACITY); @@ -296,6 +297,7 @@ pub async fn run_main( std::sync::Arc::new(config), cli_overrides, loader_overrides, + default_chatgpt_proxy_auth, feedback.clone(), config_warnings, ); diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 71d6dc338c..ec7d3e808a 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -1,3 +1,4 @@ +use clap::Parser; use codex_app_server::run_main; use codex_arg0::arg0_dispatch_or_else; use codex_common::CliConfigOverrides; @@ -8,8 +9,20 @@ use std::path::PathBuf; // managed config file without writing to /etc. const MANAGED_CONFIG_PATH_ENV_VAR: &str = "CODEX_APP_SERVER_MANAGED_CONFIG_PATH"; +#[derive(Debug, Parser, Default, Clone)] +#[command(bin_name = "codex-app-server")] +struct AppServerCli { + #[clap(flatten)] + config_overrides: CliConfigOverrides, + + /// Seed ChatGPT proxy auth (tokenless) on startup when no auth is present. + #[arg(long = "default-chatgpt-proxy-auth")] + default_chatgpt_proxy_auth: bool, +} + fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { + let cli = AppServerCli::parse(); let managed_config_path = managed_config_path_from_debug_env(); let loader_overrides = LoaderOverrides { managed_config_path, @@ -18,9 +31,10 @@ fn main() -> anyhow::Result<()> { run_main( codex_linux_sandbox_exe, - CliConfigOverrides::default(), + cli.config_overrides, loader_overrides, false, + cli.default_chatgpt_proxy_auth, ) .await?; Ok(()) diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index ced93c3bdd..c0c38df9e0 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -26,10 +26,12 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestPayload; use codex_core::AuthManager; use codex_core::ThreadManager; +use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::ExternalAuthRefreshContext; use codex_core::auth::ExternalAuthRefreshReason; use codex_core::auth::ExternalAuthRefresher; use codex_core::auth::ExternalAuthTokens; +use codex_core::auth::login_with_chatgpt_proxy; use codex_core::config::Config; use codex_core::config_loader::LoaderOverrides; use codex_core::default_client::SetOriginatorError; @@ -38,11 +40,13 @@ use codex_core::default_client::get_codex_user_agent; use codex_core::default_client::set_default_originator; use codex_feedback::CodexFeedback; use codex_protocol::ThreadId; +use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::protocol::SessionSource; use tokio::sync::broadcast; use tokio::time::Duration; use tokio::time::timeout; use toml::Value as TomlValue; +use tracing::warn; const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); @@ -115,6 +119,7 @@ impl MessageProcessor { config: Arc, cli_overrides: Vec<(String, TomlValue)>, loader_overrides: LoaderOverrides, + default_chatgpt_proxy_auth: bool, feedback: CodexFeedback, config_warnings: Vec, ) -> Self { @@ -124,6 +129,23 @@ impl MessageProcessor { false, config.cli_auth_credentials_store_mode, ); + if default_chatgpt_proxy_auth + && auth_manager.auth_cached().is_none() + && !matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) + { + let account_id = config.forced_chatgpt_workspace_id.as_deref(); + if let Err(err) = login_with_chatgpt_proxy( + &config.codex_home, + account_id, + None, + None, + AuthCredentialsStoreMode::Ephemeral, + ) { + warn!("failed to seed default ChatGPT proxy auth: {err}"); + } else { + auth_manager.reload(); + } + } auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); auth_manager.set_external_auth_refresher(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), diff --git a/codex-rs/app-server/tests/common/auth_fixtures.rs b/codex-rs/app-server/tests/common/auth_fixtures.rs index b78d5b105d..a1751a5020 100644 --- a/codex-rs/app-server/tests/common/auth_fixtures.rs +++ b/codex-rs/app-server/tests/common/auth_fixtures.rs @@ -163,6 +163,7 @@ pub fn write_chatgpt_auth( openai_api_key: None, tokens: Some(tokens), last_refresh, + chatgpt_proxy: None, }; save_auth(codex_home, &auth, cli_auth_credentials_store_mode).context("write auth.json") diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 6fa36d1ffd..a21857da61 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -75,10 +75,10 @@ impl Client { } pub fn from_auth(base_url: impl Into, auth: &CodexAuth) -> Result { - let token = auth.get_token().map_err(anyhow::Error::from)?; - let mut client = Self::new(base_url)? - .with_user_agent(get_codex_user_agent()) - .with_bearer_token(token); + let mut client = Self::new(base_url)?.with_user_agent(get_codex_user_agent()); + if let Some(token) = auth.bearer_token().map_err(anyhow::Error::from)? { + client = client.with_bearer_token(token); + } if let Some(account_id) = auth.get_account_id() { client = client.with_chatgpt_account_id(account_id); } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 7ab8bc937a..49343e33c8 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -283,6 +283,10 @@ struct AppServerCommand { /// See https://developers.openai.com/codex/config-advanced/#metrics for more details. #[arg(long = "analytics-default-enabled")] analytics_default_enabled: bool, + + /// Seed ChatGPT proxy auth (tokenless) on startup when no auth is present. + #[arg(long = "default-chatgpt-proxy-auth")] + default_chatgpt_proxy_auth: bool, } #[derive(Debug, clap::Subcommand)] @@ -535,6 +539,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() root_config_overrides, codex_core::config_loader::LoaderOverrides::default(), app_server_cli.analytics_default_enabled, + app_server_cli.default_chatgpt_proxy_auth, ) .await?; } @@ -1263,6 +1268,19 @@ mod tests { assert!(app_server.analytics_default_enabled); } + #[test] + fn app_server_default_chatgpt_proxy_auth_disabled_without_flag() { + let app_server = app_server_from_args(["codex", "app-server"].as_ref()); + assert!(!app_server.default_chatgpt_proxy_auth); + } + + #[test] + fn app_server_default_chatgpt_proxy_auth_enabled_with_flag() { + let app_server = + app_server_from_args(["codex", "app-server", "--default-chatgpt-proxy-auth"].as_ref()); + assert!(app_server.default_chatgpt_proxy_auth); + } + #[test] fn features_enable_parses_feature_name() { let cli = MultitoolCli::try_parse_from(["codex", "features", "enable", "unified_exec"]) diff --git a/codex-rs/cloud-requirements/Cargo.toml b/codex-rs/cloud-requirements/Cargo.toml index 2bfde660bf..071c98b9b4 100644 --- a/codex-rs/cloud-requirements/Cargo.toml +++ b/codex-rs/cloud-requirements/Cargo.toml @@ -9,7 +9,6 @@ workspace = true [dependencies] async-trait = { workspace = true } -codex-app-server-protocol = { workspace = true } codex-backend-client = { workspace = true } codex-core = { workspace = true } codex-otel = { workspace = true } diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index ccfa600319..800d5313f5 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -8,7 +8,6 @@ //! requirements before Codex will run. use async_trait::async_trait; -use codex_app_server_protocol::AuthMode; use codex_backend_client::Client as BackendClient; use codex_core::AuthManager; use codex_core::auth::CodexAuth; @@ -120,9 +119,7 @@ impl CloudRequirementsService { async fn fetch(&self) -> Option { let auth = self.auth_manager.auth().await?; - if !(auth.mode == AuthMode::ChatGPT - && auth.account_plan_type() == Some(PlanType::Enterprise)) - { + if !(auth.is_chatgpt_auth() && auth.account_plan_type() == Some(PlanType::Enterprise)) { return None; } diff --git a/codex-rs/core/src/api_bridge.rs b/codex-rs/core/src/api_bridge.rs index ec21f1ec85..b1f0c49c67 100644 --- a/codex-rs/core/src/api_bridge.rs +++ b/codex-rs/core/src/api_bridge.rs @@ -177,9 +177,9 @@ pub(crate) fn auth_provider_from_auth( } if let Some(auth) = auth { - let token = auth.get_token()?; + let token = auth.bearer_token()?; Ok(CoreAuthProvider { - token: Some(token), + token, account_id: auth.get_account_id(), }) } else { diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index 4bddbe3031..6923fb2e53 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -21,6 +21,7 @@ use codex_protocol::config_types::ForcedLoginMethod; pub use crate::auth::storage::AuthCredentialsStoreMode; pub use crate::auth::storage::AuthDotJson; use crate::auth::storage::AuthStorageBackend; +use crate::auth::storage::ChatGptProxyAccount; use crate::auth::storage::create_auth_storage; use crate::config::Config; use crate::error::RefreshTokenFailedError; @@ -53,6 +54,7 @@ pub enum CodexAuth { ApiKey(ApiKeyAuth), ChatGpt(ChatGptAuth), ChatGptAuthTokens(ChatGptAuthTokens), + ChatGptProxy(ChatGptProxy), } #[derive(Debug, Clone)] @@ -71,6 +73,11 @@ pub struct ChatGptAuthTokens { state: ChatGptAuthState, } +#[derive(Debug, Clone)] +pub struct ChatGptProxy { + account: ChatGptProxyAccount, +} + #[derive(Debug, Clone)] struct ChatGptAuthState { auth_dot_json: Arc>>, @@ -160,6 +167,12 @@ impl CodexAuth { return Ok(CodexAuth::from_api_key_with_client(api_key, client)); } + if let Some(proxy_account) = auth_dot_json.chatgpt_proxy.clone() { + return Ok(Self::ChatGptProxy(ChatGptProxy { + account: proxy_account, + })); + } + let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode); let state = ChatGptAuthState { auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))), @@ -189,14 +202,16 @@ impl CodexAuth { pub fn internal_auth_mode(&self) -> AuthMode { match self { Self::ApiKey(_) => AuthMode::ApiKey, - Self::ChatGpt(_) | Self::ChatGptAuthTokens(_) => AuthMode::ChatGPT, + Self::ChatGpt(_) | Self::ChatGptAuthTokens(_) | Self::ChatGptProxy(_) => { + AuthMode::ChatGPT + } } } pub fn api_auth_mode(&self) -> ApiAuthMode { match self { Self::ApiKey(_) => ApiAuthMode::ApiKey, - Self::ChatGpt(_) => ApiAuthMode::ChatGPT, + Self::ChatGpt(_) | Self::ChatGptProxy(_) => ApiAuthMode::ChatGPT, Self::ChatGptAuthTokens(_) => ApiAuthMode::ChatgptAuthTokens, } } @@ -213,7 +228,7 @@ impl CodexAuth { pub fn api_key(&self) -> Option<&str> { match self { Self::ApiKey(auth) => Some(auth.api_key.as_str()), - Self::ChatGpt(_) | Self::ChatGptAuthTokens(_) => None, + Self::ChatGpt(_) | Self::ChatGptAuthTokens(_) | Self::ChatGptProxy(_) => None, } } @@ -230,25 +245,40 @@ impl CodexAuth { } } - /// Returns the token string used for bearer authentication. - pub fn get_token(&self) -> Result { + /// Returns the token string used for bearer authentication, if available. + pub fn bearer_token(&self) -> Result, std::io::Error> { match self { - Self::ApiKey(auth) => Ok(auth.api_key.clone()), + Self::ApiKey(auth) => Ok(Some(auth.api_key.clone())), Self::ChatGpt(_) | Self::ChatGptAuthTokens(_) => { let access_token = self.get_token_data()?.access_token; - Ok(access_token) + Ok(Some(access_token)) } + Self::ChatGptProxy(_) => Ok(None), } } + /// Returns the token string used for bearer authentication. + pub fn get_token(&self) -> Result { + let Some(token) = self.bearer_token()? else { + return Err(std::io::Error::other("Bearer token is not available.")); + }; + Ok(token) + } + /// Returns `None` if `is_chatgpt_auth()` is false. pub fn get_account_id(&self) -> Option { - self.get_current_token_data().and_then(|t| t.account_id) + match self { + Self::ChatGptProxy(proxy) => proxy.account.account_id.clone(), + _ => self.get_current_token_data().and_then(|t| t.account_id), + } } /// Returns `None` if `is_chatgpt_auth()` is false. pub fn get_account_email(&self) -> Option { - self.get_current_token_data().and_then(|t| t.id_token.email) + match self { + Self::ChatGptProxy(proxy) => proxy.account.email.clone(), + _ => self.get_current_token_data().and_then(|t| t.id_token.email), + } } /// Account-facing plan classification derived from the current token. @@ -256,6 +286,9 @@ impl CodexAuth { /// mapped from the ID token's internal plan value. Prefer this when you /// need to make UI or product decisions based on the user's subscription. pub fn account_plan_type(&self) -> Option { + if let Self::ChatGptProxy(proxy) = self { + return proxy.account.plan_type; + } let map_known = |kp: &InternalKnownPlan| match kp { InternalKnownPlan::Free => AccountPlanType::Free, InternalKnownPlan::Go => AccountPlanType::Go, @@ -275,12 +308,22 @@ impl CodexAuth { }) } + /// Returns the ChatGPT workspace/account identifier when available. + pub fn chatgpt_workspace_id(&self) -> Option { + match self { + Self::ChatGptProxy(proxy) => proxy.account.account_id.clone(), + _ => self + .get_current_token_data() + .and_then(|t| t.id_token.chatgpt_account_id.or(t.account_id)), + } + } + /// Returns `None` if `is_chatgpt_auth()` is false. fn get_current_auth_json(&self) -> Option { let state = match self { Self::ChatGpt(auth) => &auth.state, Self::ChatGptAuthTokens(auth) => &auth.state, - Self::ApiKey(_) => return None, + Self::ApiKey(_) | Self::ChatGptProxy(_) => return None, }; #[expect(clippy::unwrap_used)] state.auth_dot_json.lock().unwrap().clone() @@ -303,6 +346,7 @@ impl CodexAuth { account_id: Some("account_id".to_string()), }), last_refresh: Some(Utc::now()), + chatgpt_proxy: None, }; let client = crate::default_client::create_client(); @@ -382,6 +426,7 @@ pub fn login_with_api_key( openai_api_key: Some(api_key.to_string()), tokens: None, last_refresh: None, + chatgpt_proxy: None, }; save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) } @@ -400,6 +445,28 @@ pub fn login_with_chatgpt_auth_tokens( ) } +/// Writes a tokenless ChatGPT proxy auth payload. +pub fn login_with_chatgpt_proxy( + codex_home: &Path, + account_id: Option<&str>, + email: Option<&str>, + plan_type: Option, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result<()> { + let auth_dot_json = AuthDotJson { + auth_mode: Some(ApiAuthMode::ChatGPT), + openai_api_key: None, + tokens: None, + last_refresh: None, + chatgpt_proxy: Some(ChatGptProxyAccount { + account_id: account_id.map(str::to_string), + email: email.map(str::to_string), + plan_type, + }), + }; + save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) +} + /// Persist the provided auth payload using the specified backend. pub fn save_auth( codex_home: &Path, @@ -461,23 +528,10 @@ pub fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> { return Ok(()); } - let token_data = match auth.get_token_data() { - Ok(data) => data, - Err(err) => { - return logout_with_message( - &config.codex_home, - format!( - "Failed to load ChatGPT credentials while enforcing workspace restrictions: {err}. Logging out." - ), - config.cli_auth_credentials_store_mode, - ); - } - }; - - // workspace is the external identifier for account id. - let chatgpt_account_id = token_data.id_token.chatgpt_account_id.as_deref(); - if chatgpt_account_id != Some(expected_account_id) { - let message = match chatgpt_account_id { + // Workspace is the external identifier for account id. + let chatgpt_account_id = auth.chatgpt_workspace_id(); + if chatgpt_account_id.as_deref() != Some(expected_account_id) { + let message = match chatgpt_account_id.as_deref() { Some(actual) => format!( "Login is restricted to workspace {expected_account_id}, but current credentials belong to {actual}. Logging out." ), @@ -731,6 +785,7 @@ impl AuthDotJson { openai_api_key: None, tokens: Some(tokens), last_refresh: Some(Utc::now()), + chatgpt_proxy: None, } } @@ -1143,7 +1198,7 @@ impl AuthManager { self.reload(); Ok(()) } - CodexAuth::ApiKey(_) => Ok(()), + CodexAuth::ApiKey(_) | CodexAuth::ChatGptProxy(_) => Ok(()), } } @@ -1399,6 +1454,7 @@ mod tests { account_id: None, }), last_refresh: Some(last_refresh), + chatgpt_proxy: None, }, auth_dot_json ); @@ -1432,6 +1488,7 @@ mod tests { openai_api_key: Some("sk-test-key".to_string()), tokens: None, last_refresh: None, + chatgpt_proxy: None, }; super::save_auth(dir.path(), &auth_dot_json, AuthCredentialsStoreMode::File)?; let auth_file = get_auth_file(dir.path()); diff --git a/codex-rs/core/src/auth/storage.rs b/codex-rs/core/src/auth/storage.rs index b4f4bb8e7e..4ed00ddb5e 100644 --- a/codex-rs/core/src/auth/storage.rs +++ b/codex-rs/core/src/auth/storage.rs @@ -23,6 +23,7 @@ use crate::token_data::TokenData; use codex_app_server_protocol::AuthMode; use codex_keyring_store::DefaultKeyringStore; use codex_keyring_store::KeyringStore; +use codex_protocol::account::PlanType as AccountPlanType; use once_cell::sync::Lazy; /// Determine where Codex should store CLI auth credentials. @@ -54,6 +55,23 @@ pub struct AuthDotJson { #[serde(default, skip_serializing_if = "Option::is_none")] pub last_refresh: Option>, + + /// ChatGPT account metadata supplied by a trusted proxy. + #[serde( + default, + skip_serializing_if = "Option::is_none", + rename = "chatgptProxy" + )] + pub chatgpt_proxy: Option, +} + +/// Account metadata for the tokenless ChatGPT proxy auth mode. +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ChatGptProxyAccount { + pub account_id: Option, + pub email: Option, + pub plan_type: Option, } pub(super) fn get_auth_file(codex_home: &Path) -> PathBuf { @@ -353,6 +371,7 @@ mod tests { openai_api_key: Some("test-key".to_string()), tokens: None, last_refresh: Some(Utc::now()), + chatgpt_proxy: None, }; storage @@ -373,6 +392,7 @@ mod tests { openai_api_key: Some("test-key".to_string()), tokens: None, last_refresh: Some(Utc::now()), + chatgpt_proxy: None, }; let file = get_auth_file(codex_home.path()); @@ -395,6 +415,7 @@ mod tests { openai_api_key: Some("sk-test-key".to_string()), tokens: None, last_refresh: None, + chatgpt_proxy: None, }; let storage = create_auth_storage(dir.path().to_path_buf(), AuthCredentialsStoreMode::File); storage.save(&auth_dot_json)?; @@ -418,6 +439,7 @@ mod tests { openai_api_key: Some("sk-ephemeral".to_string()), tokens: None, last_refresh: Some(Utc::now()), + chatgpt_proxy: None, }; storage.save(&auth_dot_json)?; @@ -516,6 +538,7 @@ mod tests { account_id: Some(format!("{prefix}-account-id")), }), last_refresh: None, + chatgpt_proxy: None, } } @@ -532,6 +555,7 @@ mod tests { openai_api_key: Some("sk-test".to_string()), tokens: None, last_refresh: None, + chatgpt_proxy: None, }; seed_keyring_with_auth( &mock_keyring, @@ -574,6 +598,7 @@ mod tests { account_id: Some("account".to_string()), }), last_refresh: Some(Utc::now()), + chatgpt_proxy: None, }; storage.save(&auth)?; diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index 12c61ddaf1..613e0b1e92 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -39,7 +39,7 @@ fn codex_apps_mcp_bearer_token_env_var() -> Option { } fn codex_apps_mcp_bearer_token(auth: Option<&CodexAuth>) -> Option { - let token = auth.and_then(|auth| auth.get_token().ok())?; + let token = auth.and_then(|auth| auth.bearer_token().ok()).flatten()?; let token = token.trim(); if token.is_empty() { None diff --git a/codex-rs/core/tests/suite/auth_refresh.rs b/codex-rs/core/tests/suite/auth_refresh.rs index 4ef3b82eec..e768fff10d 100644 --- a/codex-rs/core/tests/suite/auth_refresh.rs +++ b/codex-rs/core/tests/suite/auth_refresh.rs @@ -55,6 +55,7 @@ async fn refresh_token_succeeds_updates_storage() -> Result<()> { openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), + chatgpt_proxy: None, }; ctx.write_auth(&initial_auth)?; @@ -117,6 +118,7 @@ async fn returns_fresh_tokens_as_is() -> Result<()> { openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), + chatgpt_proxy: None, }; ctx.write_auth(&initial_auth)?; @@ -163,6 +165,7 @@ async fn refreshes_token_when_last_refresh_is_stale() -> Result<()> { openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(stale_refresh), + chatgpt_proxy: None, }; ctx.write_auth(&initial_auth)?; @@ -222,6 +225,7 @@ async fn refresh_token_returns_permanent_error_for_expired_refresh_token() -> Re openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), + chatgpt_proxy: None, }; ctx.write_auth(&initial_auth)?; @@ -272,6 +276,7 @@ async fn refresh_token_returns_transient_error_on_server_failure() -> Result<()> openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), + chatgpt_proxy: None, }; ctx.write_auth(&initial_auth)?; @@ -324,6 +329,7 @@ async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> { openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), + chatgpt_proxy: None, }; ctx.write_auth(&initial_auth)?; @@ -333,6 +339,7 @@ async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> { openai_api_key: None, tokens: Some(disk_tokens.clone()), last_refresh: Some(initial_last_refresh), + chatgpt_proxy: None, }; save_auth( ctx.codex_home.path(), @@ -416,6 +423,7 @@ async fn unauthorized_recovery_skips_reload_on_account_mismatch() -> Result<()> openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), + chatgpt_proxy: None, }; ctx.write_auth(&initial_auth)?; @@ -431,6 +439,7 @@ async fn unauthorized_recovery_skips_reload_on_account_mismatch() -> Result<()> openai_api_key: None, tokens: Some(disk_tokens), last_refresh: Some(initial_last_refresh), + chatgpt_proxy: None, }; save_auth( ctx.codex_home.path(), @@ -495,6 +504,7 @@ async fn unauthorized_recovery_requires_chatgpt_auth() -> Result<()> { openai_api_key: Some("sk-test".to_string()), tokens: None, last_refresh: None, + chatgpt_proxy: None, }; ctx.write_auth(&auth)?; diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 5272c84eaa..746850e222 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -564,6 +564,7 @@ pub(crate) async fn persist_tokens_async( openai_api_key: api_key, tokens: Some(tokens), last_refresh: Some(Utc::now()), + chatgpt_proxy: None, }; save_auth(&codex_home, &auth, auth_credentials_store_mode) }) diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index 0a801227f5..9c7e8b6b13 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -91,7 +91,7 @@ pub(crate) fn compose_account_display( let auth = auth_manager.auth_cached()?; match auth { - CodexAuth::ChatGpt(_) | CodexAuth::ChatGptAuthTokens(_) => { + CodexAuth::ChatGpt(_) | CodexAuth::ChatGptAuthTokens(_) | CodexAuth::ChatGptProxy(_) => { let email = auth.get_account_email(); let plan = plan .map(|plan_type| title_case(format!("{plan_type:?}").as_str()))