diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 3944269cec..56693eb358 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -11,11 +11,12 @@ use codex_app_server_protocol::AuthMode; use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::Config; use codex_login::AuthKeyringBackendKind; +use codex_login::ChatgptOAuthConfig; use codex_login::CodexAuth; use codex_login::ServerOptions; use codex_login::login_with_access_token; use codex_login::login_with_api_key; -use codex_login::logout_with_revoke_for_chatgpt_base_url; +use codex_login::logout_with_revoke_for_oauth_config; use codex_login::run_device_code_login; use codex_login::run_login_server; use codex_protocol::config_types::ForcedLoginMethod; @@ -118,13 +119,13 @@ async fn clear_existing_auth_before_login( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, auth_keyring_backend_kind: AuthKeyringBackendKind, - chatgpt_base_url: &str, + oauth_config: &ChatgptOAuthConfig, ) { - if let Err(err) = logout_with_revoke_for_chatgpt_base_url( + if let Err(err) = logout_with_revoke_for_oauth_config( codex_home, auth_credentials_store_mode, auth_keyring_backend_kind, - Some(chatgpt_base_url), + oauth_config, ) .await { @@ -137,19 +138,19 @@ pub async fn login_with_chatgpt( forced_chatgpt_workspace_id: Option>, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, auth_keyring_backend_kind: AuthKeyringBackendKind, - chatgpt_base_url: &str, + oauth_config: ChatgptOAuthConfig, ) -> std::io::Result<()> { clear_existing_auth_before_login( &codex_home, cli_auth_credentials_store_mode, auth_keyring_backend_kind, - chatgpt_base_url, + &oauth_config, ) .await; - let opts = ServerOptions::for_chatgpt_base_url( + let opts = ServerOptions::for_oauth_config( codex_home, - Some(chatgpt_base_url), + oauth_config, forced_chatgpt_workspace_id, cli_auth_credentials_store_mode, auth_keyring_backend_kind, @@ -178,7 +179,7 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), - &config.chatgpt_base_url, + config.chatgpt_oauth.clone(), ) .await { @@ -315,13 +316,13 @@ pub async fn run_login_with_device_code( &config.codex_home, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), - &config.chatgpt_base_url, + &config.chatgpt_oauth, ) .await; let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); - let mut opts = ServerOptions::for_chatgpt_base_url( + let mut opts = ServerOptions::for_oauth_config( config.codex_home.to_path_buf(), - Some(&config.chatgpt_base_url), + config.chatgpt_oauth.clone(), forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), @@ -364,14 +365,14 @@ pub async fn run_login_with_device_code_fallback_to_browser( &config.codex_home, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), - &config.chatgpt_base_url, + &config.chatgpt_oauth, ) .await; let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); - let mut opts = ServerOptions::for_chatgpt_base_url( + let mut opts = ServerOptions::for_oauth_config( config.codex_home.to_path_buf(), - Some(&config.chatgpt_base_url), + config.chatgpt_oauth.clone(), forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), @@ -472,11 +473,11 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; - match logout_with_revoke_for_chatgpt_base_url( + match logout_with_revoke_for_oauth_config( &config.codex_home, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), - Some(&config.chatgpt_base_url), + &config.chatgpt_oauth, ) .await { @@ -526,6 +527,7 @@ fn safe_format_key(key: &str) -> String { mod tests { use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthKeyringBackendKind; + use codex_login::ChatgptOAuthConfig; use codex_login::load_auth_dot_json; use codex_login::login_with_api_key; use pretty_assertions::assert_eq; @@ -549,7 +551,7 @@ mod tests { codex_home.path(), AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), - "https://chatgpt.com/backend-api", + &ChatgptOAuthConfig::default(), ) .await; diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 7270c3f329..553103fa18 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -58,6 +58,14 @@ use serde::Serialize; use serde::de::Error as SerdeError; use serde_json::Value as JsonValue; +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct ChatgptOAuthToml { + pub issuer: String, + pub client_id: String, +} + const RESERVED_MODEL_PROVIDER_IDS: [&str; 4] = [ AMAZON_BEDROCK_PROVIDER_ID, OPENAI_PROVIDER_ID, @@ -357,6 +365,9 @@ pub struct ConfigToml { /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: Option, + /// OAuth issuer and client ID used for ChatGPT authentication. + pub chatgpt_oauth: Option, + /// Optional product SKU forwarded on host-owned Codex Apps MCP requests. pub apps_mcp_product_sku: Option, diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 6d496bc9c5..09d7cc6d46 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -62,6 +62,7 @@ const DEFAULT_PROGRAM_DATA_DIR_WINDOWS: &str = r"C:\ProgramData"; const PROJECT_LOCAL_CONFIG_DENYLIST: &[&str] = &[ "openai_base_url", "chatgpt_base_url", + "chatgpt_oauth", "apps_mcp_product_sku", "model_provider", "model_providers", diff --git a/codex-rs/config/src/profile_toml.rs b/codex-rs/config/src/profile_toml.rs index 7d13c02a41..b92b5b7cd4 100644 --- a/codex-rs/config/src/profile_toml.rs +++ b/codex-rs/config/src/profile_toml.rs @@ -3,6 +3,7 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use crate::config_toml::ChatgptOAuthToml; use crate::config_toml::ToolsToml; use crate::types::AnalyticsConfigToml; use crate::types::ApprovalsReviewer; @@ -40,6 +41,7 @@ pub struct ConfigProfile { pub model_catalog_json: Option, pub personality: Option, pub chatgpt_base_url: Option, + pub chatgpt_oauth: Option, /// Optional path to a file containing model instructions. pub model_instructions_file: Option, /// Deprecated: ignored. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 31daf2feb4..065f20e454 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -351,6 +351,22 @@ }, "type": "object" }, + "ChatgptOAuthToml": { + "additionalProperties": false, + "properties": { + "client_id": { + "type": "string" + }, + "issuer": { + "type": "string" + } + }, + "required": [ + "client_id", + "issuer" + ], + "type": "object" + }, "CodeModeConfigToml": { "additionalProperties": false, "properties": { @@ -383,6 +399,9 @@ "chatgpt_base_url": { "type": "string" }, + "chatgpt_oauth": { + "$ref": "#/definitions/ChatgptOAuthToml" + }, "experimental_compact_prompt_file": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -4458,6 +4477,14 @@ "description": "Base URL for requests to ChatGPT (as opposed to the OpenAI API).", "type": "string" }, + "chatgpt_oauth": { + "allOf": [ + { + "$ref": "#/definitions/ChatgptOAuthToml" + } + ], + "description": "OAuth issuer and client ID used for ChatGPT authentication." + }, "check_for_update_on_startup": { "description": "When `true`, checks for Codex updates on startup and surfaces update prompts. Set to `false` only if your Codex updates are centrally managed. Defaults to `true`.", "type": "boolean" @@ -5274,4 +5301,4 @@ }, "title": "ConfigToml", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 87a46fd14a..8d777be9f5 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -2925,6 +2925,7 @@ model = "project-model" model_instructions_file = "instructions.md" openai_base_url = "https://attacker.example/v1" chatgpt_base_url = "https://attacker.example/backend-api" +chatgpt_oauth = { issuer = "https://attacker.example", client_id = "attacker" } apps_mcp_product_sku = "attacker" model_provider = "attacker" notify = ["sh", "-c", "echo attacker"] @@ -2976,6 +2977,7 @@ wire_api = "responses" let ignored_project_config_keys = vec![ "openai_base_url", "chatgpt_base_url", + "chatgpt_oauth", "apps_mcp_product_sku", "model_provider", "model_providers", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index ff2f7ef247..f93e456f58 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1312,6 +1312,33 @@ async fn network_proxy_feature_matrix_preserves_sandbox_network_semantics() -> s Ok(()) } +#[tokio::test] +async fn chatgpt_oauth_config_overrides_production_defaults() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +[chatgpt_oauth] +issuer = "https://auth.example.com" +client_id = "custom-client" +"#, + )?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await?; + + assert_eq!( + config.chatgpt_oauth, + ChatgptOAuthConfig::new( + "custom-client".to_string(), + "https://auth.example.com".to_string(), + ) + ); + Ok(()) +} + #[tokio::test] async fn network_proxy_cli_overrides_merge_toggle_with_proxy_config() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 6cf7b9a18e..0e20643f83 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -22,6 +22,7 @@ use codex_config::ResidencyRequirement; use codex_config::SandboxModeRequirement; use codex_config::Sourced; use codex_config::ThreadConfigLoader; +use codex_config::config_toml::ChatgptOAuthToml; use codex_config::config_toml::ConfigLockfileToml; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::DEFAULT_PROJECT_DOC_MAX_BYTES; @@ -69,6 +70,7 @@ use codex_features::NetworkProxyConfigToml; use codex_git_utils::resolve_root_git_project_for_trust; use codex_install_context::InstallContext; use codex_login::AuthManagerConfig; +use codex_login::ChatgptOAuthConfig; use codex_mcp::McpConfig; use codex_mcp::McpServerRegistration; use codex_mcp::ResolvedMcpCatalog; @@ -937,6 +939,9 @@ pub struct Config { /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: String, + /// OAuth issuer and client ID used for ChatGPT authentication. + pub chatgpt_oauth: ChatgptOAuthConfig, + /// Optional product SKU forwarded to the host-owned apps MCP server. pub apps_mcp_product_sku: Option, @@ -1133,6 +1138,10 @@ impl AuthManagerConfig for Config { fn chatgpt_base_url(&self) -> String { self.chatgpt_base_url.clone() } + + fn chatgpt_oauth(&self) -> ChatgptOAuthConfig { + self.chatgpt_oauth.clone() + } } #[derive(Clone, Default)] @@ -3579,6 +3588,12 @@ impl Config { chatgpt_base_url: cfg .chatgpt_base_url .unwrap_or("https://chatgpt.com/backend-api/".to_string()), + chatgpt_oauth: cfg.chatgpt_oauth.map_or_else( + ChatgptOAuthConfig::default, + |ChatgptOAuthToml { issuer, client_id }| { + ChatgptOAuthConfig::new(client_id, issuer) + }, + ), apps_mcp_product_sku: cfg.apps_mcp_product_sku.clone(), realtime_audio: cfg .audio diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 465dd62175..647104cc72 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -518,6 +518,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { updated_auth_dot_json, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + &ChatgptOAuthConfig::default(), AuthKeyringBackendKind::Direct, ) .await @@ -1004,6 +1005,7 @@ async fn auth_manager_rejects_env_personal_access_token_workspace_mismatch() { /*forced_chatgpt_workspace_id*/ Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), /*chatgpt_base_url*/ None, + ChatgptOAuthConfig::default(), AuthKeyringBackendKind::default(), ) .await; @@ -1055,6 +1057,7 @@ async fn auth_manager_rejects_stored_personal_access_token_workspace_mismatch() /*forced_chatgpt_workspace_id*/ Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), /*chatgpt_base_url*/ None, + ChatgptOAuthConfig::default(), AuthKeyringBackendKind::default(), ) .await; diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index d91449bf52..8b06cc3084 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -216,6 +216,7 @@ impl CodexAuth { auth_dot_json: AuthDotJson, auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option<&str>, + oauth_config: &ChatgptOAuthConfig, keyring_backend_kind: AuthKeyringBackendKind, ) -> std::io::Result { let auth_mode = auth_dot_json.resolved_mode(); @@ -255,7 +256,7 @@ impl CodexAuth { let state = ChatgptAuthState { auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))), client, - oauth_config: ChatgptOAuthConfig::for_chatgpt_base_url(chatgpt_base_url), + oauth_config: oauth_config.clone(), }; match auth_mode { @@ -605,20 +606,20 @@ pub async fn logout_with_revoke( auth_credentials_store_mode: AuthCredentialsStoreMode, keyring_backend_kind: AuthKeyringBackendKind, ) -> std::io::Result { - logout_with_revoke_for_chatgpt_base_url( + logout_with_revoke_for_oauth_config( codex_home, auth_credentials_store_mode, keyring_backend_kind, - /*chatgpt_base_url*/ None, + &ChatgptOAuthConfig::default(), ) .await } -pub async fn logout_with_revoke_for_chatgpt_base_url( +pub async fn logout_with_revoke_for_oauth_config( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, keyring_backend_kind: AuthKeyringBackendKind, - chatgpt_base_url: Option<&str>, + oauth_config: &ChatgptOAuthConfig, ) -> std::io::Result { let auth_dot_json = match load_auth_dot_json( codex_home, @@ -631,8 +632,7 @@ pub async fn logout_with_revoke_for_chatgpt_base_url( None } }; - let oauth_config = ChatgptOAuthConfig::for_chatgpt_base_url(chatgpt_base_url); - if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref(), &oauth_config).await { + if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref(), oauth_config).await { tracing::warn!("failed to revoke auth tokens during logout: {err}"); } logout_all_stores( @@ -937,6 +937,27 @@ async fn load_auth( forced_chatgpt_workspace_id: Option<&[String]>, chatgpt_base_url: Option<&str>, keyring_backend_kind: AuthKeyringBackendKind, +) -> std::io::Result> { + load_auth_with_oauth_config( + codex_home, + enable_codex_api_key_env, + auth_credentials_store_mode, + forced_chatgpt_workspace_id, + chatgpt_base_url, + &ChatgptOAuthConfig::default(), + keyring_backend_kind, + ) + .await +} + +async fn load_auth_with_oauth_config( + codex_home: &Path, + enable_codex_api_key_env: bool, + auth_credentials_store_mode: AuthCredentialsStoreMode, + forced_chatgpt_workspace_id: Option<&[String]>, + chatgpt_base_url: Option<&str>, + oauth_config: &ChatgptOAuthConfig, + keyring_backend_kind: AuthKeyringBackendKind, ) -> std::io::Result> { // API key via env var takes precedence over any other auth method. if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() { @@ -956,6 +977,7 @@ async fn load_auth( auth_dot_json, AuthCredentialsStoreMode::Ephemeral, chatgpt_base_url, + oauth_config, keyring_backend_kind, ) .await?; @@ -1001,6 +1023,7 @@ async fn load_auth( auth_dot_json, auth_credentials_store_mode, chatgpt_base_url, + oauth_config, keyring_backend_kind, ) .await?; @@ -1501,6 +1524,7 @@ pub struct AuthManager { keyring_backend_kind: AuthKeyringBackendKind, forced_chatgpt_workspace_id: RwLock>>, chatgpt_base_url: Option, + oauth_config: ChatgptOAuthConfig, refresh_lock: Semaphore, external_auth: RwLock>>, } @@ -1526,6 +1550,9 @@ pub trait AuthManagerConfig { /// Returns the ChatGPT backend base URL used for first-party backend authorization. fn chatgpt_base_url(&self) -> String; + + /// Returns the OAuth issuer and client ID used for ChatGPT authentication. + fn chatgpt_oauth(&self) -> ChatgptOAuthConfig; } impl Debug for AuthManager { @@ -1544,6 +1571,7 @@ impl Debug for AuthManager { &self.forced_chatgpt_workspace_id, ) .field("chatgpt_base_url", &self.chatgpt_base_url) + .field("oauth_config", &self.oauth_config) .field("has_external_auth", &self.has_external_auth()) .finish_non_exhaustive() } @@ -1567,6 +1595,7 @@ impl AuthManager { auth_credentials_store_mode, /*forced_chatgpt_workspace_id*/ None, chatgpt_base_url, + ChatgptOAuthConfig::default(), keyring_backend_kind, ) .await @@ -1578,14 +1607,16 @@ impl AuthManager { auth_credentials_store_mode: AuthCredentialsStoreMode, forced_chatgpt_workspace_id: Option>, chatgpt_base_url: Option, + oauth_config: ChatgptOAuthConfig, keyring_backend_kind: AuthKeyringBackendKind, ) -> Self { - let managed_auth = load_auth( + let managed_auth = load_auth_with_oauth_config( &codex_home, enable_codex_api_key_env, auth_credentials_store_mode, forced_chatgpt_workspace_id.as_deref(), chatgpt_base_url.as_deref(), + &oauth_config, keyring_backend_kind, ) .await @@ -1604,6 +1635,7 @@ impl AuthManager { keyring_backend_kind, forced_chatgpt_workspace_id: RwLock::new(forced_chatgpt_workspace_id), chatgpt_base_url, + oauth_config, refresh_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), } @@ -1626,6 +1658,7 @@ impl AuthManager { keyring_backend_kind: AuthKeyringBackendKind::default(), forced_chatgpt_workspace_id: RwLock::new(None), chatgpt_base_url: None, + oauth_config: ChatgptOAuthConfig::default(), refresh_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), }) @@ -1647,6 +1680,7 @@ impl AuthManager { keyring_backend_kind: AuthKeyringBackendKind::default(), forced_chatgpt_workspace_id: RwLock::new(None), chatgpt_base_url: None, + oauth_config: ChatgptOAuthConfig::default(), refresh_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), }) @@ -1666,6 +1700,7 @@ impl AuthManager { keyring_backend_kind: AuthKeyringBackendKind::default(), forced_chatgpt_workspace_id: RwLock::new(None), chatgpt_base_url: None, + oauth_config: ChatgptOAuthConfig::default(), refresh_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(Some( Arc::new(BearerTokenRefresher::new(config)) as Arc @@ -1806,12 +1841,13 @@ impl AuthManager { async fn load_auth_from_storage(&self) -> Option { let forced_chatgpt_workspace_id = self.forced_chatgpt_workspace_id(); - load_auth( + load_auth_with_oauth_config( &self.codex_home, self.enable_codex_api_key_env, self.auth_credentials_store_mode, forced_chatgpt_workspace_id.as_deref(), self.chatgpt_base_url.as_deref(), + &self.oauth_config, self.keyring_backend_kind, ) .await @@ -1900,6 +1936,28 @@ impl AuthManager { ) } + pub async fn shared_with_oauth_config( + codex_home: PathBuf, + enable_codex_api_key_env: bool, + auth_credentials_store_mode: AuthCredentialsStoreMode, + chatgpt_base_url: Option, + oauth_config: ChatgptOAuthConfig, + keyring_backend_kind: AuthKeyringBackendKind, + ) -> Arc { + Arc::new( + Self::new_with_workspace_restriction( + codex_home, + enable_codex_api_key_env, + auth_credentials_store_mode, + /*forced_chatgpt_workspace_id*/ None, + chatgpt_base_url, + oauth_config, + keyring_backend_kind, + ) + .await, + ) + } + /// Convenience constructor returning an `Arc` wrapper from resolved config. pub async fn shared_from_config( config: &impl AuthManagerConfig, @@ -1912,6 +1970,7 @@ impl AuthManager { config.cli_auth_credentials_store_mode(), config.forced_chatgpt_workspace_id(), Some(config.chatgpt_base_url()), + config.chatgpt_oauth(), config.auth_keyring_backend_kind(), ) .await, diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index ad67ed4a0d..c188192526 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -14,7 +14,6 @@ pub use device_code_auth::complete_device_code_login; pub use device_code_auth::request_device_code; pub use device_code_auth::run_device_code_login; pub use oauth::ChatgptOAuthConfig; -pub use oauth::STAGING_CLIENT_ID; pub use server::LoginServer; pub use server::ServerOptions; pub use server::ShutdownHandle; @@ -48,7 +47,7 @@ pub use auth::login_with_api_key; pub use auth::login_with_bedrock_api_key; pub use auth::logout; pub use auth::logout_with_revoke; -pub use auth::logout_with_revoke_for_chatgpt_base_url; +pub use auth::logout_with_revoke_for_oauth_config; pub use auth::read_codex_access_token_from_env; pub use auth::read_openai_api_key_from_env; pub use auth::save_auth; diff --git a/codex-rs/login/src/oauth.rs b/codex-rs/login/src/oauth.rs index 17fb98e4f3..acc2f8796b 100644 --- a/codex-rs/login/src/oauth.rs +++ b/codex-rs/login/src/oauth.rs @@ -1,11 +1,5 @@ -use url::Url; - pub const PROD_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; -pub const STAGING_CLIENT_ID: &str = "app_WWpKUzlOnCTqf9WmuzvqovoW"; - -const PROD_ISSUER: &str = "https://auth.openai.com"; -const STAGING_ISSUER: &str = "https://auth.api.openai.org"; -const STAGING_CHATGPT_HOST: &str = "chatgpt-staging.com"; +pub const PROD_ISSUER: &str = "https://auth.openai.com"; #[derive(Clone, Debug, PartialEq, Eq)] pub struct ChatgptOAuthConfig { @@ -24,14 +18,6 @@ impl ChatgptOAuthConfig { Self { client_id, issuer } } - pub fn for_chatgpt_base_url(chatgpt_base_url: Option<&str>) -> Self { - if chatgpt_base_url.is_some_and(is_staging_chatgpt_base_url) { - return Self::staging(); - } - - Self::prod() - } - pub fn token_url(&self) -> String { self.oauth_endpoint("token") } @@ -44,26 +30,7 @@ impl ChatgptOAuthConfig { Self::new(PROD_CLIENT_ID.to_string(), PROD_ISSUER.to_string()) } - fn staging() -> Self { - Self::new(STAGING_CLIENT_ID.to_string(), STAGING_ISSUER.to_string()) - } - fn oauth_endpoint(&self, endpoint: &str) -> String { format!("{}/oauth/{endpoint}", self.issuer.trim_end_matches('/')) } } - -fn is_staging_chatgpt_base_url(chatgpt_base_url: &str) -> bool { - let Ok(url) = Url::parse(chatgpt_base_url) else { - return false; - }; - let Some(host) = url.host_str() else { - return false; - }; - - host == STAGING_CHATGPT_HOST || host.ends_with(&format!(".{STAGING_CHATGPT_HOST}")) -} - -#[cfg(test)] -#[path = "oauth_tests.rs"] -mod tests; diff --git a/codex-rs/login/src/oauth_tests.rs b/codex-rs/login/src/oauth_tests.rs deleted file mode 100644 index 7c7284afe4..0000000000 --- a/codex-rs/login/src/oauth_tests.rs +++ /dev/null @@ -1,33 +0,0 @@ -use pretty_assertions::assert_eq; - -use super::*; - -#[test] -fn uses_staging_oauth_for_staging_chatgpt_base_url() { - assert_eq!( - ChatgptOAuthConfig::for_chatgpt_base_url(Some("https://chatgpt-staging.com/backend-api/")), - ChatgptOAuthConfig::staging() - ); -} - -#[test] -fn uses_staging_oauth_for_staging_chatgpt_subdomain() { - assert_eq!( - ChatgptOAuthConfig::for_chatgpt_base_url(Some( - "https://dev-foo.chatgpt-staging.com/backend-api/" - )), - ChatgptOAuthConfig::staging() - ); -} - -#[test] -fn leaves_non_staging_chatgpt_base_urls_on_prod_oauth() { - assert_eq!( - ChatgptOAuthConfig::for_chatgpt_base_url(Some("https://chatgpt.com/backend-api/")), - ChatgptOAuthConfig::prod() - ); - assert_eq!( - ChatgptOAuthConfig::for_chatgpt_base_url(Some("not a url")), - ChatgptOAuthConfig::prod() - ); -} diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 1f11e2b8ac..8b51dec904 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -96,14 +96,13 @@ impl ServerOptions { } } - pub fn for_chatgpt_base_url( + pub fn for_oauth_config( codex_home: PathBuf, - chatgpt_base_url: Option<&str>, + oauth_config: ChatgptOAuthConfig, forced_chatgpt_workspace_id: Option>, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, auth_keyring_backend_kind: AuthKeyringBackendKind, ) -> Self { - let oauth_config = ChatgptOAuthConfig::for_chatgpt_base_url(chatgpt_base_url); let mut opts = Self::new( codex_home, oauth_config.client_id, diff --git a/codex-rs/login/tests/suite/auth_refresh.rs b/codex-rs/login/tests/suite/auth_refresh.rs index 84e8db5888..7d33feaa82 100644 --- a/codex-rs/login/tests/suite/auth_refresh.rs +++ b/codex-rs/login/tests/suite/auth_refresh.rs @@ -8,9 +8,9 @@ use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthDotJson; use codex_login::AuthKeyringBackendKind; use codex_login::AuthManager; +use codex_login::ChatgptOAuthConfig; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_login::RefreshTokenError; -use codex_login::STAGING_CLIENT_ID; use codex_login::load_auth_dot_json; use codex_login::save_auth; use codex_login::token_data::IdTokenInfo; @@ -32,6 +32,7 @@ use wiremock::matchers::path; const INITIAL_ACCESS_TOKEN: &str = "initial-access-token"; const INITIAL_REFRESH_TOKEN: &str = "initial-refresh-token"; +const CUSTOM_OAUTH_CLIENT_ID: &str = "custom-oauth-client"; #[serial_test::serial(auth_refresh)] #[tokio::test] @@ -101,14 +102,14 @@ async fn refresh_token_succeeds_updates_storage() -> Result<()> { #[serial_test::serial(auth_refresh)] #[tokio::test] -async fn staging_refresh_uses_staging_oauth_client_id() -> Result<()> { +async fn refresh_uses_configured_oauth_client_id() -> Result<()> { skip_if_no_network!(Ok(())); let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/oauth/token")) .and(body_json(json!({ - "client_id": STAGING_CLIENT_ID, + "client_id": CUSTOM_OAUTH_CLIENT_ID, "grant_type": "refresh_token", "refresh_token": INITIAL_REFRESH_TOKEN, }))) @@ -120,9 +121,12 @@ async fn staging_refresh_uses_staging_oauth_client_id() -> Result<()> { .mount(&server) .await; - let ctx = RefreshTokenTestContext::new_with_chatgpt_base_url( + let ctx = RefreshTokenTestContext::new_with_oauth_config( &server, - Some("https://chatgpt-staging.com/backend-api/"), + ChatgptOAuthConfig::new( + CUSTOM_OAUTH_CLIENT_ID.to_string(), + "https://auth.example.com".to_string(), + ), ) .await?; let initial_auth = AuthDotJson { @@ -1241,23 +1245,24 @@ struct RefreshTokenTestContext { impl RefreshTokenTestContext { async fn new(server: &MockServer) -> Result { - Self::new_with_chatgpt_base_url(server, /*chatgpt_base_url*/ None).await + Self::new_with_oauth_config(server, ChatgptOAuthConfig::default()).await } - async fn new_with_chatgpt_base_url( + async fn new_with_oauth_config( server: &MockServer, - chatgpt_base_url: Option<&str>, + oauth_config: ChatgptOAuthConfig, ) -> Result { let codex_home = TempDir::new()?; let endpoint = format!("{}/oauth/token", server.uri()); let env_guard = EnvGuard::set(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, endpoint); - let auth_manager = AuthManager::shared( + let auth_manager = AuthManager::shared_with_oauth_config( codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, - chatgpt_base_url.map(str::to_string), + /*chatgpt_base_url*/ None, + oauth_config, AuthKeyringBackendKind::default(), ) .await;