diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index e7a533d76bc0..e025a61a3344 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -536,6 +536,13 @@ ], "type": "string" }, + { + "description": "Backend auth supplied as request headers.", + "enum": [ + "headers" + ], + "type": "string" + }, { "description": "Programmatic Codex auth backed by a registered Agent Identity.", "enum": [ 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 1102135a7184..b7526b080da1 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 @@ -6951,6 +6951,13 @@ ], "type": "string" }, + { + "description": "Backend auth supplied as request headers.", + "enum": [ + "headers" + ], + "type": "string" + }, { "description": "Programmatic Codex auth backed by a registered Agent Identity.", "enum": [ 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 29dfe78034f9..e1afc7883a51 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 @@ -1077,6 +1077,13 @@ ], "type": "string" }, + { + "description": "Backend auth supplied as request headers.", + "enum": [ + "headers" + ], + "type": "string" + }, { "description": "Programmatic Codex auth backed by a registered Agent Identity.", "enum": [ 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 5c1847c56cda..710e0eceddae 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json @@ -25,6 +25,13 @@ ], "type": "string" }, + { + "description": "Backend auth supplied as request headers.", + "enum": [ + "headers" + ], + "type": "string" + }, { "description": "Programmatic Codex auth backed by a registered Agent Identity.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts b/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts index 77bc037ebe23..b248876173fa 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" | "personalAccessToken" | "bedrockApiKey"; +export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "headers" | "agentIdentity" | "personalAccessToken" | "bedrockApiKey"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index e693e8d4514a..27a8b0205da7 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -31,6 +31,11 @@ pub enum AuthMode { #[ts(rename = "chatgptAuthTokens")] #[strum(serialize = "chatgptAuthTokens")] ChatgptAuthTokens, + /// Backend auth supplied as request headers. + #[serde(rename = "headers")] + #[ts(rename = "headers")] + #[strum(serialize = "headers")] + Headers, /// Programmatic Codex auth backed by a registered Agent Identity. #[serde(rename = "agentIdentity")] #[ts(rename = "agentIdentity")] @@ -53,7 +58,7 @@ impl AuthMode { pub fn has_chatgpt_account(self) -> bool { match self { Self::Chatgpt | Self::ChatgptAuthTokens | Self::PersonalAccessToken => true, - Self::ApiKey | Self::AgentIdentity | Self::BedrockApiKey => false, + Self::ApiKey | Self::Headers | Self::AgentIdentity | Self::BedrockApiKey => false, } } @@ -62,6 +67,7 @@ impl AuthMode { match self { Self::Chatgpt | Self::ChatgptAuthTokens + | Self::Headers | Self::AgentIdentity | Self::PersonalAccessToken => true, Self::ApiKey | Self::BedrockApiKey => false, diff --git a/codex-rs/app-server/src/auth_mode.rs b/codex-rs/app-server/src/auth_mode.rs index 67d3ee61b985..d54347078153 100644 --- a/codex-rs/app-server/src/auth_mode.rs +++ b/codex-rs/app-server/src/auth_mode.rs @@ -12,6 +12,7 @@ pub(crate) fn auth_mode_to_api(auth_mode: AuthMode) -> ApiAuthMode { AuthMode::ApiKey => ApiAuthMode::ApiKey, AuthMode::Chatgpt => ApiAuthMode::Chatgpt, AuthMode::ChatgptAuthTokens => ApiAuthMode::ChatgptAuthTokens, + AuthMode::Headers => ApiAuthMode::Headers, AuthMode::AgentIdentity => ApiAuthMode::AgentIdentity, AuthMode::PersonalAccessToken => ApiAuthMode::PersonalAccessToken, AuthMode::BedrockApiKey => ApiAuthMode::BedrockApiKey, 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 25bb3a75436a..f4acc5a8e45a 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -855,7 +855,9 @@ impl AccountRequestProcessor { let auth_mode = auth_mode_to_api(auth.api_auth_mode()); let (reported_auth_method, token_opt) = if matches!( auth, - CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) + CodexAuth::Headers(_) + | CodexAuth::AgentIdentity(_) + | CodexAuth::PersonalAccessToken(_) ) || include_token && permanent_refresh_failure { diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index ef587cd1d4eb..cfb9316e91d2 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -1327,6 +1327,7 @@ fn stored_auth_mode(auth: &codex_login::AuthDotJson) -> &'static str { AuthMode::ApiKey => "api_key", AuthMode::Chatgpt => "chatgpt", AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens", + AuthMode::Headers => "headers", AuthMode::AgentIdentity => "agent_identity", AuthMode::PersonalAccessToken => "personal_access_token", AuthMode::BedrockApiKey => "bedrock_api_key", @@ -1397,6 +1398,9 @@ fn stored_auth_issues( issues.push("external ChatGPT auth is missing refresh metadata"); } } + AuthMode::Headers => { + issues.push("header auth cannot be loaded from auth storage"); + } AuthMode::AgentIdentity => { if auth .agent_identity @@ -2466,6 +2470,7 @@ fn auth_mode_name(auth: &CodexAuth) -> &'static str { AuthMode::ApiKey => "api_key", AuthMode::Chatgpt => "chatgpt", AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens", + AuthMode::Headers => "headers", AuthMode::AgentIdentity => "agent_identity", AuthMode::PersonalAccessToken => "personal_access_token", AuthMode::BedrockApiKey => "bedrock_api_key", @@ -2605,6 +2610,7 @@ fn provider_auth_reachability_mode_from_auth( Some( AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens + | AuthMode::Headers | AuthMode::AgentIdentity | AuthMode::PersonalAccessToken, ) diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index d3f9b7b96b02..98d56bec12f9 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -449,6 +449,9 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { eprintln!("Logged in using ChatGPT"); std::process::exit(0); } + AuthMode::Headers => { + unreachable!("header auth cannot be loaded from auth storage") + } AuthMode::AgentIdentity => { eprintln!("Logged in using access token"); std::process::exit(0); diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index c7cfff8a9293..a5257ae44fed 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -69,7 +69,13 @@ pub use codex_extension_api::empty_extension_registry; pub use codex_features::Feature; pub use codex_features::Features; pub use codex_home::CodexHomeUserInstructionsProvider; +pub use codex_login::AuthHeaders; pub use codex_login::AuthManager; +pub use codex_login::CodexAuth; +pub use codex_login::ExternalAuth; +pub use codex_login::ExternalAuthFuture; +pub use codex_login::ExternalAuthRefreshContext; +pub use codex_login::ExternalAuthRefreshReason; pub use codex_login::default_client::set_default_originator; pub use codex_model_provider_info::OPENAI_PROVIDER_ID; pub use codex_model_provider_info::built_in_model_providers; diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 0bf33b39933c..8c98a3372eae 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -2141,6 +2141,7 @@ impl AuthRequestTelemetryContext { AuthMode::ApiKey | AuthMode::BedrockApiKey => "ApiKey", AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens + | AuthMode::Headers | AuthMode::AgentIdentity | AuthMode::PersonalAccessToken => "Chatgpt", }), diff --git a/codex-rs/core/tests/suite/external_auth.rs b/codex-rs/core/tests/suite/external_auth.rs new file mode 100644 index 000000000000..5649615d1fd5 --- /dev/null +++ b/codex-rs/core/tests/suite/external_auth.rs @@ -0,0 +1,65 @@ +use std::sync::Arc; + +use codex_login::AuthHeaders; +use codex_login::CodexAuth; +use codex_login::ExternalAuth; +use codex_login::ExternalAuthFuture; +use codex_login::ExternalAuthRefreshContext; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use reqwest::header::AUTHORIZATION; +use reqwest::header::HeaderMap; +use reqwest::header::HeaderValue; + +#[derive(Clone)] +struct StaticExternalAuth(CodexAuth); + +impl ExternalAuth for StaticExternalAuth { + fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> { + Box::pin(async { Ok(self.0.clone()) }) + } + + fn refresh(&self, _context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> { + self.resolve() + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn external_header_auth_is_attached_to_responses_requests() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let response_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer external")); + headers.insert("x-external-auth", HeaderValue::from_static("enabled")); + let external_auth = StaticExternalAuth(CodexAuth::Headers(AuthHeaders::new(headers))); + let mut builder = test_codex(); + let test = builder.build_with_auto_env(&server).await?; + test.thread_manager + .auth_manager() + .set_external_auth(Arc::new(external_auth)) + .await?; + + test.submit_turn("hello").await?; + + let request = response_mock.single_request(); + assert_eq!( + request.header("authorization").as_deref(), + Some("Bearer external") + ); + assert_eq!( + request.header("x-external-auth").as_deref(), + Some("enabled") + ); + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 9b22222d7c2a..e8bb48856cdb 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -54,6 +54,7 @@ mod exec; mod exec_policy; #[cfg(not(target_os = "windows"))] mod extension_sandbox; +mod external_auth; mod fork_thread; #[cfg(not(target_os = "windows"))] mod guardian_review; diff --git a/codex-rs/login/src/auth/auth_headers.rs b/codex-rs/login/src/auth/auth_headers.rs new file mode 100644 index 000000000000..802f00c4d363 --- /dev/null +++ b/codex-rs/login/src/auth/auth_headers.rs @@ -0,0 +1,30 @@ +use std::fmt; + +use reqwest::header::HeaderMap; + +/// Request headers returned by an external auth provider. +/// +/// The provider owns credential validation, rotation, and persistence. Codex +/// keeps the resolved headers in memory and attaches them to backend requests. +#[derive(Clone, PartialEq, Eq)] +pub struct AuthHeaders { + headers: HeaderMap, +} + +impl AuthHeaders { + pub fn new(headers: HeaderMap) -> Self { + Self { headers } + } + + pub fn headers(&self) -> &HeaderMap { + &self.headers + } +} + +impl fmt::Debug for AuthHeaders { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AuthHeaders") + .field("headers", &"") + .finish() + } +} diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 0b0bfc882d13..780cd8600984 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -1093,6 +1093,61 @@ async fn unauthorized_recovery_uses_external_refresh_for_bearer_manager() { assert_eq!(refreshed_token.as_deref(), Some("refreshed-provider-token")); } +#[derive(Clone)] +struct StaticExternalAuth(CodexAuth); + +impl ExternalAuth for StaticExternalAuth { + fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> { + Box::pin(async { Ok(self.0.clone()) }) + } + + fn refresh(&self, _context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> { + Box::pin(async { Ok(self.0.clone()) }) + } +} + +#[tokio::test] +async fn external_auth_provider_can_install_headers() { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + reqwest::header::HeaderValue::from_static("Bearer external"), + ); + headers.insert( + "x-external-auth", + reqwest::header::HeaderValue::from_static("enabled"), + ); + let auth = CodexAuth::Headers(AuthHeaders::new(headers)); + let codex_home = tempdir().expect("tempdir"); + let manager = AuthManager::new( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::Ephemeral, + /*forced_chatgpt_workspace_id*/ None, + /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, + ) + .await; + + manager + .set_external_auth(Arc::new(StaticExternalAuth(auth.clone()))) + .await + .expect("external auth should install"); + + assert_eq!(manager.auth_cached(), Some(auth)); + assert!( + manager + .auth_cached() + .is_some_and(|auth| auth.uses_codex_backend()) + ); + assert!( + !manager + .auth_cached() + .is_some_and(|auth| auth.is_chatgpt_auth()) + ); +} + struct ProviderAuthScript { tempdir: TempDir, command: String, diff --git a/codex-rs/login/src/auth/bedrock_api_key_tests.rs b/codex-rs/login/src/auth/bedrock_api_key_tests.rs index 1f8fab68b2c9..182834af6631 100644 --- a/codex-rs/login/src/auth/bedrock_api_key_tests.rs +++ b/codex-rs/login/src/auth/bedrock_api_key_tests.rs @@ -85,6 +85,7 @@ async fn login_with_bedrock_api_key_replaces_openai_auth() -> anyhow::Result<()> CodexAuth::ApiKey(_) | CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) + | CodexAuth::Headers(_) | CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) => None, }), @@ -148,6 +149,7 @@ async fn bedrock_only_auth_storage_creates_primary_auth() -> anyhow::Result<()> CodexAuth::ApiKey(_) | CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) + | CodexAuth::Headers(_) | CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) => None, }), diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index dfc46b8f8536..9509efc348c3 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -38,6 +38,7 @@ use super::agent_identity::require_agent_identity_authapi_base_url; use super::agent_identity::verified_record_from_jwt; use super::external_bearer::BearerTokenRefresher; use super::revoke::revoke_auth_tokens; +use crate::auth::AuthHeaders; pub use crate::auth::agent_identity::AgentIdentityAuth; pub use crate::auth::agent_identity::AgentIdentityAuthError; pub use crate::auth::bedrock_api_key::BedrockApiKeyAuth; @@ -71,6 +72,7 @@ pub enum CodexAuth { ApiKey(ApiKeyAuth), Chatgpt(ChatgptAuth), ChatgptAuthTokens(ChatgptAuthTokens), + Headers(AuthHeaders), AgentIdentity(AgentIdentityAuth), PersonalAccessToken(PersonalAccessTokenAuth), BedrockApiKey(BedrockApiKeyAuth), @@ -145,6 +147,7 @@ impl AgentIdentityBootstrapCooldown { impl PartialEq for CodexAuth { fn eq(&self, other: &Self) -> bool { match (self, other) { + (Self::Headers(a), Self::Headers(b)) => a == b, (Self::PersonalAccessToken(a), Self::PersonalAccessToken(b)) => a == b, (Self::BedrockApiKey(a), Self::BedrockApiKey(b)) => a == b, _ => self.api_auth_mode() == other.api_auth_mode(), @@ -308,6 +311,11 @@ impl CodexAuth { }; return Ok(Self::BedrockApiKey(auth)); } + if auth_mode == AuthMode::Headers { + return Err(std::io::Error::other( + "externally provided auth cannot be loaded from auth storage.", + )); + } let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode); let client = create_default_auth_client(&refresh_token_endpoint(), auth_route_config)?; @@ -327,6 +335,9 @@ impl CodexAuth { } AuthMode::ChatgptAuthTokens => Ok(Self::ChatgptAuthTokens(ChatgptAuthTokens { state })), AuthMode::ApiKey => unreachable!("api key mode is handled above"), + AuthMode::Headers => { + unreachable!("externally provided auth is never loaded from auth storage") + } AuthMode::AgentIdentity => unreachable!("agent identity mode is handled above"), AuthMode::PersonalAccessToken => { unreachable!("personal access token mode is handled above") @@ -409,6 +420,7 @@ impl CodexAuth { match self { Self::ApiKey(_) => AuthMode::ApiKey, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => AuthMode::Chatgpt, + Self::Headers(_) => AuthMode::Headers, Self::AgentIdentity(_) => AuthMode::AgentIdentity, Self::PersonalAccessToken(_) => AuthMode::PersonalAccessToken, Self::BedrockApiKey(_) => AuthMode::BedrockApiKey, @@ -421,6 +433,7 @@ impl CodexAuth { Self::ApiKey(_) => AuthMode::ApiKey, Self::Chatgpt(_) => AuthMode::Chatgpt, Self::ChatgptAuthTokens(_) => AuthMode::ChatgptAuthTokens, + Self::Headers(_) => AuthMode::Headers, Self::AgentIdentity(_) => AuthMode::AgentIdentity, Self::PersonalAccessToken(_) => AuthMode::PersonalAccessToken, Self::BedrockApiKey(_) => AuthMode::BedrockApiKey, @@ -448,7 +461,10 @@ impl CodexAuth { } fn supports_unauthorized_recovery(&self) -> bool { - matches!(self, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_)) + matches!( + self, + Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) | Self::Headers(_) + ) } /// Returns `None` if `auth_mode() != AuthMode::ApiKey`. @@ -457,6 +473,7 @@ impl CodexAuth { Self::ApiKey(auth) => Some(auth.api_key.as_str()), Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) + | Self::Headers(_) | Self::AgentIdentity(_) | Self::PersonalAccessToken(_) | Self::BedrockApiKey(_) => None, @@ -487,6 +504,9 @@ impl CodexAuth { Self::AgentIdentity(_) => Err(std::io::Error::other( "agent identity auth does not expose a bearer token", )), + Self::Headers(_) => Err(std::io::Error::other( + "header auth does not expose a bearer token", + )), Self::PersonalAccessToken(auth) => Ok(auth.access_token().to_string()), Self::BedrockApiKey(_) => Err(std::io::Error::other( "Bedrock API key auth does not expose a Codex bearer token", @@ -497,6 +517,7 @@ impl CodexAuth { /// Returns `None` if Codex backend auth does not expose an account id. pub fn get_account_id(&self) -> Option { match self { + Self::Headers(_) => None, 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), @@ -506,6 +527,7 @@ impl CodexAuth { /// Returns false if Codex backend auth omits the FedRAMP claim. pub fn is_fedramp_account(&self) -> bool { match self { + Self::Headers(_) => false, Self::AgentIdentity(auth) => auth.is_fedramp_account(), Self::PersonalAccessToken(auth) => auth.is_fedramp_account(), _ => self @@ -517,6 +539,7 @@ impl CodexAuth { /// Returns `None` if Codex backend auth does not expose an account email. pub fn get_account_email(&self) -> Option { match self { + Self::Headers(_) => None, Self::AgentIdentity(auth) => auth.email().map(str::to_string), Self::PersonalAccessToken(auth) => auth.email().map(str::to_string), _ => self.get_current_token_data().and_then(|t| t.id_token.email), @@ -526,6 +549,7 @@ impl CodexAuth { /// Returns `None` if Codex backend auth does not expose a ChatGPT user id. pub fn get_chatgpt_user_id(&self) -> Option { match self { + Self::Headers(_) => None, Self::AgentIdentity(auth) => Some(auth.chatgpt_user_id().to_string()), Self::PersonalAccessToken(auth) => Some(auth.chatgpt_user_id().to_string()), _ => self @@ -538,6 +562,9 @@ impl CodexAuth { /// Returns a high-level `AccountPlanType` (e.g., Free/Plus/Pro/Team/…) /// for UI or product decisions based on the user's subscription. pub fn account_plan_type(&self) -> Option { + if matches!(self, Self::Headers(_)) { + return None; + } if let Self::AgentIdentity(auth) = self { return Some(auth.plan_type()); } @@ -564,6 +591,7 @@ impl CodexAuth { Self::Chatgpt(auth) => &auth.state, Self::ChatgptAuthTokens(auth) => &auth.state, Self::ApiKey(_) + | Self::Headers(_) | Self::AgentIdentity(_) | Self::PersonalAccessToken(_) | Self::BedrockApiKey(_) => return None, @@ -609,6 +637,7 @@ impl CodexAuth { Self::AgentIdentity(auth) => Ok(Some(auth.clone())), Self::ApiKey(_) | Self::ChatgptAuthTokens(_) + | Self::Headers(_) | Self::PersonalAccessToken(_) | Self::BedrockApiKey(_) => Ok(None), Self::Chatgpt(_) => { @@ -1055,10 +1084,12 @@ async fn enforce_login_restrictions_with_agent_identity_authapi_base_url( | (ForcedLoginMethod::Api, AuthMode::BedrockApiKey) => None, (ForcedLoginMethod::Chatgpt, AuthMode::Chatgpt) | (ForcedLoginMethod::Chatgpt, AuthMode::ChatgptAuthTokens) + | (ForcedLoginMethod::Chatgpt, AuthMode::Headers) | (ForcedLoginMethod::Chatgpt, AuthMode::AgentIdentity) | (ForcedLoginMethod::Chatgpt, AuthMode::PersonalAccessToken) => None, (ForcedLoginMethod::Api, AuthMode::Chatgpt) | (ForcedLoginMethod::Api, AuthMode::ChatgptAuthTokens) + | (ForcedLoginMethod::Api, AuthMode::Headers) | (ForcedLoginMethod::Api, AuthMode::AgentIdentity) | (ForcedLoginMethod::Api, AuthMode::PersonalAccessToken) => Some( "API key login is required, but ChatGPT is currently being used. Logging out." @@ -1083,7 +1114,9 @@ async fn enforce_login_restrictions_with_agent_identity_authapi_base_url( if let Some(expected_account_ids) = config.forced_chatgpt_workspace_id.as_deref() { let chatgpt_account_id = match &auth { - CodexAuth::ApiKey(_) | CodexAuth::BedrockApiKey(_) => return Ok(()), + CodexAuth::ApiKey(_) | CodexAuth::Headers(_) | CodexAuth::BedrockApiKey(_) => { + return Ok(()); + } CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) => { auth.get_account_id() } @@ -2117,6 +2150,7 @@ impl AuthManager { | (AuthMode::ChatgptAuthTokens, AuthMode::ChatgptAuthTokens) => { a.get_current_auth_json() == b.get_current_auth_json() } + (AuthMode::Headers, AuthMode::Headers) => a == b, (AuthMode::AgentIdentity, AuthMode::AgentIdentity) => match (a, b) { (CodexAuth::AgentIdentity(a), CodexAuth::AgentIdentity(b)) => { a.record() == b.record() @@ -2406,6 +2440,7 @@ impl AuthManager { } CodexAuth::ApiKey(_) | CodexAuth::ChatgptAuthTokens(_) + | CodexAuth::Headers(_) | CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) | CodexAuth::BedrockApiKey(_) => Ok(()), diff --git a/codex-rs/login/src/auth/mod.rs b/codex-rs/login/src/auth/mod.rs index d87c7cc6adb6..16477d78279e 100644 --- a/codex-rs/login/src/auth/mod.rs +++ b/codex-rs/login/src/auth/mod.rs @@ -1,5 +1,6 @@ mod access_token; mod agent_identity; +mod auth_headers; mod bedrock_api_key; pub mod default_client; pub mod error; @@ -11,6 +12,7 @@ mod external_bearer; mod manager; mod revoke; +pub use auth_headers::AuthHeaders; pub use bedrock_api_key::BedrockApiKeyAuth; pub use bedrock_api_key::login_with_bedrock_api_key; pub use error::RefreshTokenFailedError; diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index c5a3c1e28bba..9b3f82f3829a 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -25,6 +25,7 @@ pub use success_page::LoginSuccessPageBrand; pub use auth::AgentIdentityAuthPolicy; pub use auth::AuthConfig; pub use auth::AuthDotJson; +pub use auth::AuthHeaders; pub use auth::AuthKeyringBackendKind; pub use auth::AuthManager; pub use auth::AuthManagerConfig; diff --git a/codex-rs/model-provider-info/src/lib.rs b/codex-rs/model-provider-info/src/lib.rs index 65517cbd43a2..61cd717d588e 100644 --- a/codex-rs/model-provider-info/src/lib.rs +++ b/codex-rs/model-provider-info/src/lib.rs @@ -244,6 +244,7 @@ impl ModelProviderInfo { Some( AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens + | AuthMode::Headers | AuthMode::AgentIdentity | AuthMode::PersonalAccessToken ) 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 5949f36bb8fb..9dbb23ecc19d 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 @@ -148,6 +148,15 @@ fn test_personal_access_token_uses_chatgpt_codex_base_url() { assert_eq!(api_provider.base_url, CHATGPT_CODEX_BASE_URL); } +#[test] +fn test_header_auth_uses_chatgpt_codex_base_url() { + let api_provider = ModelProviderInfo::create_openai_provider(/*base_url*/ None) + .to_api_provider(Some(AuthMode::Headers)) + .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/amazon_bedrock/mod.rs b/codex-rs/model-provider/src/amazon_bedrock/mod.rs index 108308c1bfae..9fdf937bcb0c 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/mod.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/mod.rs @@ -69,6 +69,7 @@ impl AmazonBedrockModelProvider { CodexAuth::ApiKey(_) | CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) + | CodexAuth::Headers(_) | CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) => None, }) diff --git a/codex-rs/model-provider/src/auth.rs b/codex-rs/model-provider/src/auth.rs index 5b667e454973..bcd36f19121d 100644 --- a/codex-rs/model-provider/src/auth.rs +++ b/codex-rs/model-provider/src/auth.rs @@ -7,6 +7,7 @@ use codex_agent_identity::authorization_header_for_agent_task; use codex_api::AgentIdentityTelemetry; use codex_api::AuthProvider; use codex_api::SharedAuthProvider; +use codex_login::AuthHeaders; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::auth::AgentIdentityAuth; @@ -109,6 +110,17 @@ impl AuthProvider for AgentIdentityAuthProvider { } } +#[derive(Clone, Debug)] +struct HeaderAuthProvider { + auth: AuthHeaders, +} + +impl AuthProvider for HeaderAuthProvider { + fn add_auth_headers(&self, headers: &mut HeaderMap) { + headers.extend(self.auth.headers().clone()); + } +} + // Some providers are meant to send no auth headers. Examples include local OSS // providers and custom test providers with `requires_openai_auth = false`. #[derive(Clone, Debug)] @@ -243,6 +255,7 @@ pub fn auth_provider_from_auth(auth: &CodexAuth) -> SharedAuthProvider { CodexAuth::AgentIdentity(auth) => { Arc::new(AgentIdentityAuthProvider { auth: auth.clone() }) } + CodexAuth::Headers(auth) => Arc::new(HeaderAuthProvider { auth: auth.clone() }), CodexAuth::BedrockApiKey(_) => unreachable!("{BEDROCK_API_KEY_UNSUPPORTED_MESSAGE}"), CodexAuth::ApiKey(_) | CodexAuth::Chatgpt(_) @@ -388,6 +401,21 @@ mod tests { assert!(auth.to_auth_headers().is_empty()); } + #[test] + fn header_auth_adds_predefined_headers() { + let mut expected = HeaderMap::new(); + expected.insert( + http::header::AUTHORIZATION, + HeaderValue::from_static("Bearer external"), + ); + expected.insert("x-external-auth", HeaderValue::from_static("enabled")); + let auth = CodexAuth::Headers(AuthHeaders::new(expected.clone())); + + let actual = auth_provider_from_auth(&auth).to_auth_headers(); + + assert_eq!(actual, expected); + } + #[test] fn openai_provider_rejects_bedrock_api_key_auth() { let provider = ModelProviderInfo::create_openai_provider(/*base_url*/ None); diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index 5f101e52cd6b..2b8cb67b97a6 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -277,6 +277,9 @@ impl ModelProvider for ConfiguredModelProvider { if auth_manager.refresh_failure_for_auth(&auth).is_some() { return None; } + if matches!(auth, CodexAuth::Headers(_)) { + return None; + } Some(auth) }) .map(|auth| match &auth { @@ -286,6 +289,7 @@ impl ModelProvider for ConfiguredModelProvider { } CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) + | CodexAuth::Headers(_) | CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) => { let email = auth.get_account_email(); diff --git a/codex-rs/otel/src/lib.rs b/codex-rs/otel/src/lib.rs index 87ccbbfeee80..65b034cc3179 100644 --- a/codex-rs/otel/src/lib.rs +++ b/codex-rs/otel/src/lib.rs @@ -59,6 +59,7 @@ impl From for TelemetryAuthMode { AuthMode::ApiKey | AuthMode::BedrockApiKey => Self::ApiKey, AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens + | AuthMode::Headers | AuthMode::AgentIdentity | AuthMode::PersonalAccessToken => Self::Chatgpt, } diff --git a/codex-rs/protocol/src/auth.rs b/codex-rs/protocol/src/auth.rs index 7c506e87eb9d..a537c0268de6 100644 --- a/codex-rs/protocol/src/auth.rs +++ b/codex-rs/protocol/src/auth.rs @@ -15,6 +15,10 @@ pub enum AuthMode { #[serde(rename = "chatgptAuthTokens")] #[strum(serialize = "chatgptAuthTokens")] ChatgptAuthTokens, + /// Codex backend auth supplied as request headers. + #[serde(rename = "headers")] + #[strum(serialize = "headers")] + Headers, /// Programmatic Codex auth backed by a registered Agent Identity. #[serde(rename = "agentIdentity")] #[strum(serialize = "agentIdentity")] @@ -34,7 +38,7 @@ impl AuthMode { pub fn has_chatgpt_account(self) -> bool { match self { Self::Chatgpt | Self::ChatgptAuthTokens | Self::PersonalAccessToken => true, - Self::ApiKey | Self::AgentIdentity | Self::BedrockApiKey => false, + Self::ApiKey | Self::Headers | Self::AgentIdentity | Self::BedrockApiKey => false, } } @@ -43,6 +47,7 @@ impl AuthMode { match self { Self::Chatgpt | Self::ChatgptAuthTokens + | Self::Headers | Self::AgentIdentity | Self::PersonalAccessToken => true, Self::ApiKey | Self::BedrockApiKey => false, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index aeca17091e94..ab29dfd5988f 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1207,7 +1207,7 @@ pub(crate) fn status_account_display_from_auth_mode( email: None, plan: plan_type.map(plan_type_display_name), }), - Some(AuthMode::BedrockApiKey) => None, + Some(AuthMode::Headers) | Some(AuthMode::BedrockApiKey) => None, None => None, } } diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index c5c4ef442d87..619163bc6601 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -952,6 +952,7 @@ impl AuthModeWidget { ApiAuthMode::ApiKey => AuthMode::ApiKey, ApiAuthMode::Chatgpt => AuthMode::Chatgpt, ApiAuthMode::ChatgptAuthTokens => AuthMode::ChatgptAuthTokens, + ApiAuthMode::Headers => AuthMode::Headers, ApiAuthMode::AgentIdentity => AuthMode::AgentIdentity, ApiAuthMode::PersonalAccessToken => AuthMode::PersonalAccessToken, ApiAuthMode::BedrockApiKey => AuthMode::BedrockApiKey,