PAC 2 - Add shared auth system proxy contract (#26707)

## Summary

Stacked on #26706.

Adds the shared auth/system-proxy contract that later platform resolver
PRs plug into. This PR moves Codex-owned auth and startup HTTP clients
through a common route-aware boundary, but does not yet add Windows or
macOS system proxy resolution.

The default path remains unchanged when `respect_system_proxy` is absent
or disabled.

## Implementation

- Adds `codex-client/src/outbound_proxy.rs` with the shared
route-selection model:
  - `OutboundProxyConfig`;
  - `ClientRouteClass`;
  - `RouteFailureClass`;
  - `build_reqwest_client_for_route`.
- Preserves the existing reqwest/default-client behavior when no route
config is supplied.
- Uses the fixed MVP routing policy when route config is supplied:
platform system/PAC/WPAD discovery, then explicit env proxy variables,
then direct connection.
- Keeps platform-specific system discovery behind the shared client
boundary. This PR provides the contract and fallback behavior; later
resolver PRs plug in Windows and macOS discovery.
- Adds `login::AuthRouteConfig` so auth call sites depend on a small
policy type instead of platform resolver details.
- Maps the resolved `Config.respect_system_proxy` boolean into
`AuthRouteConfig` for auth-owned clients.
- Wires the route config through browser login, device-code login,
access-token login, login status, logout/revoke, token refresh, API-key
exchange, app-server account login, TUI/app startup, cloud-config
bootstrap, cloud tasks, plugin auth, and exec startup config loading.

## End-user behavior

- No behavior changes by default.
- When `respect_system_proxy = true`, auth-owned clients opt into the
shared route-aware client path.
- On platforms without a resolver implementation in this PR, system
discovery is unavailable and the route-aware path falls back to explicit
env proxy handling, then direct connection.
- Custom CA handling remains separate from proxy route selection and
still runs through the shared client builder.
- No proxy URLs, PAC contents, or resolved platform details are exposed
through the public config surface introduced here.

## Tests

Adds or updates coverage for:

- preserving default auth-client fallback behavior when no route config
is provided;
- injected environment-proxy fallback without mutating process
environment;
- existing login-server E2E flows using explicit `auth_route_config:
None` to guard unchanged default behavior;
- updated auth manager, login, logout, cloud-config, startup, and
plugin-auth call sites passing route config explicitly.
This commit is contained in:
canvrno-oai authored and GitHub committed 2026-06-22 13:03:11 -07:00
1 parent e48ab86693
commit 1659c4a629
37 files changed
+885 -54

No files matched your search

+33 -10
View File
@@ -3,6 +3,9 @@ use std::sync::Arc;
use codex_agent_identity::AgentIdentityKey;
use codex_agent_identity::ChatGptEnvironment;
use codex_agent_identity::agent_identity_jwks_url;
use codex_agent_identity::agent_registration_url;
use codex_agent_identity::agent_task_registration_url;
use codex_agent_identity::build_abom;
use codex_agent_identity::decode_agent_identity_jwt;
use codex_agent_identity::fetch_agent_identity_jwks;
@@ -15,7 +18,8 @@ use codex_protocol::account::PlanType as AccountPlanType;
use codex_protocol::protocol::SessionSource;
use thiserror::Error;
use crate::default_client::build_reqwest_client;
use crate::default_client::build_default_auth_reqwest_client;
use crate::outbound_proxy::AuthRouteConfig;
use super::storage::AgentIdentityAuthRecord;
@@ -96,13 +100,18 @@ impl AgentIdentityAuth {
pub async fn from_record(
mut record: AgentIdentityAuthRecord,
agent_identity_authapi_base_url: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<Self> {
public_key_ssh_from_private_key_pkcs8_base64(&record.agent_private_key)
.map_err(std::io::Error::other)?;
if record_needs_task_registration(&record) {
record.task_id = Some(
register_task_for_record_with_retries(&record, agent_identity_authapi_base_url)
.await?,
register_task_for_record_with_retries(
&record,
agent_identity_authapi_base_url,
auth_route_config,
)
.await?,
);
}
Ok(Self {
@@ -114,9 +123,10 @@ impl AgentIdentityAuth {
jwt: &str,
chatgpt_base_url: &str,
agent_identity_authapi_base_url: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<Self> {
let record = verified_record_from_jwt(jwt, chatgpt_base_url).await?;
Self::from_record(record, agent_identity_authapi_base_url).await
let record = verified_record_from_jwt(jwt, chatgpt_base_url, auth_route_config).await?;
Self::from_record(record, agent_identity_authapi_base_url, auth_route_config).await
}
#[cfg(test)]
@@ -163,9 +173,11 @@ pub(super) async fn register_managed_chatgpt_agent_identity(
binding: ManagedChatGptAgentIdentityBinding,
agent_identity_authapi_base_url: &str,
session_source: SessionSource,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<AgentIdentityAuth> {
let key_material = generate_agent_key_material().map_err(std::io::Error::other)?;
let client = build_reqwest_client();
let registration_url = agent_registration_url(agent_identity_authapi_base_url);
let client = build_default_auth_reqwest_client(&registration_url, auth_route_config)?;
let runtime_id = retry_registration(|| async {
register_agent_identity(
&client,
@@ -200,7 +212,7 @@ pub(super) async fn register_managed_chatgpt_agent_identity(
chatgpt_account_is_fedramp: binding.chatgpt_account_is_fedramp,
task_id: None,
};
AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url)
AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url, auth_route_config)
.await
.map_err(|err| classify_bootstrap_error("agent task registration", err))
}
@@ -208,9 +220,12 @@ pub(super) async fn register_managed_chatgpt_agent_identity(
pub(super) async fn verified_record_from_jwt(
jwt: &str,
chatgpt_base_url: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<AgentIdentityAuthRecord> {
AgentIdentityAuthRecord::from_agent_identity_jwt(jwt)?;
let jwks = fetch_agent_identity_jwks(&build_reqwest_client(), chatgpt_base_url)
let jwks_url = agent_identity_jwks_url(chatgpt_base_url);
let client = build_default_auth_reqwest_client(&jwks_url, auth_route_config)?;
let jwks = fetch_agent_identity_jwks(&client, chatgpt_base_url)
.await
.map_err(std::io::Error::other)?;
let claims = decode_agent_identity_jwt(jwt, Some(&jwks)).map_err(std::io::Error::other)?;
@@ -285,19 +300,24 @@ where
async fn register_task_for_record_with_retries(
record: &AgentIdentityAuthRecord,
agent_identity_authapi_base_url: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<String> {
let task_registration_url =
agent_task_registration_url(agent_identity_authapi_base_url, &record.agent_runtime_id);
let client = build_default_auth_reqwest_client(&task_registration_url, auth_route_config)?;
retry_registration(|| async {
register_task_for_record(record, agent_identity_authapi_base_url).await
register_task_for_record(&client, record, agent_identity_authapi_base_url).await
})
.await
}
async fn register_task_for_record(
client: &reqwest::Client,
record: &AgentIdentityAuthRecord,
agent_identity_authapi_base_url: &str,
) -> std::io::Result<String> {
register_agent_task(
&build_reqwest_client(),
client,
agent_identity_authapi_base_url,
key_for_record(record),
)
@@ -370,6 +390,7 @@ mod tests {
let auth = AgentIdentityAuth::from_record(
agent_identity_record_with_generated_key(),
&server.uri(),
/*auth_route_config*/ None,
)
.await?;
@@ -414,6 +435,7 @@ mod tests {
&jwt,
&format!("{}/backend-api", server.uri()),
&server.uri(),
/*auth_route_config*/ None,
)
.await?;
@@ -456,6 +478,7 @@ mod tests {
let auth = AgentIdentityAuth::from_record(
agent_identity_record_with_generated_key(),
&server.uri(),
/*auth_route_config*/ None,
)
.await?;
+45
View File
@@ -122,6 +122,7 @@ async fn login_with_access_token_writes_agent_identity_jwt() {
/*forced_chatgpt_workspace_id*/ None,
Some(&chatgpt_base_url),
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await
.expect("login_with_access_token should succeed");
@@ -181,6 +182,7 @@ async fn stored_agent_identity_jwt_keeps_auth_json_unchanged() -> anyhow::Result
Some(&chatgpt_base_url),
AuthKeyringBackendKind::Direct,
Some(&authapi_base_url),
/*auth_route_config*/ None,
)
.await?
.expect("auth should load");
@@ -226,6 +228,7 @@ async fn login_with_access_token_writes_only_personal_access_token() {
Some(&allowed_workspaces),
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await
.expect("personal access token login should succeed");
@@ -278,6 +281,7 @@ async fn login_with_access_token_rejects_personal_access_token_workspace_mismatc
Some(&allowed_workspaces),
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await
.expect_err("personal access token workspace mismatch should fail");
@@ -310,6 +314,7 @@ async fn login_with_access_token_rejects_invalid_personal_access_token() {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await
.expect_err("invalid personal access token should fail");
@@ -333,6 +338,7 @@ async fn login_with_access_token_rejects_invalid_jwt() {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await
.expect_err("invalid access token should fail");
@@ -364,6 +370,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result<
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await?
.expect("auth should load");
@@ -373,6 +380,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result<
AgentIdentityAuthPolicy::JwtOnly,
/*agent_identity_authapi_base_url*/ None,
/*forced_chatgpt_workspace_id*/ None,
/*auth_route_config*/ None,
SessionSource::Cli,
)
.await?
@@ -403,6 +411,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result<
AgentIdentityAuthPolicy::ChatGptAuth,
Some(&server.uri()),
/*forced_chatgpt_workspace_id*/ None,
/*auth_route_config*/ None,
SessionSource::Cli,
)
.await?
@@ -412,6 +421,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result<
AgentIdentityAuthPolicy::ChatGptAuth,
Some(&server.uri()),
/*forced_chatgpt_workspace_id*/ None,
/*auth_route_config*/ None,
SessionSource::Cli,
)
.await?
@@ -442,6 +452,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result<
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await?
.expect("auth should reload");
@@ -450,6 +461,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result<
AgentIdentityAuthPolicy::ChatGptAuth,
Some(&server.uri()),
/*forced_chatgpt_workspace_id*/ None,
/*auth_route_config*/ None,
SessionSource::Cli,
)
.await?
@@ -482,6 +494,7 @@ async fn chatgpt_auth_retries_transient_agent_identity_registration() -> anyhow:
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await?
.expect("auth should load");
@@ -510,6 +523,7 @@ async fn chatgpt_auth_retries_transient_agent_identity_registration() -> anyhow:
AgentIdentityAuthPolicy::ChatGptAuth,
Some(&server.uri()),
/*forced_chatgpt_workspace_id*/ None,
/*auth_route_config*/ None,
SessionSource::Cli,
)
.await?
@@ -546,6 +560,7 @@ async fn chatgpt_auth_registration_retry_exhaustion_is_fallback_eligible() -> an
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await?
.expect("auth should load");
@@ -563,6 +578,7 @@ async fn chatgpt_auth_registration_retry_exhaustion_is_fallback_eligible() -> an
AgentIdentityAuthPolicy::ChatGptAuth,
Some(&server.uri()),
/*forced_chatgpt_workspace_id*/ None,
/*auth_route_config*/ None,
SessionSource::Cli,
)
.await
@@ -605,6 +621,7 @@ async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible()
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await?
.expect("auth should load");
@@ -625,6 +642,7 @@ async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible()
AgentIdentityAuthPolicy::ChatGptAuth,
Some(&server.uri()),
/*forced_chatgpt_workspace_id*/ None,
/*auth_route_config*/ None,
SessionSource::Cli,
)
.await
@@ -659,6 +677,7 @@ async fn chatgpt_auth_non_retryable_registration_error_is_hard_failure() -> anyh
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await?
.expect("auth should load");
@@ -676,6 +695,7 @@ async fn chatgpt_auth_non_retryable_registration_error_is_hard_failure() -> anyh
AgentIdentityAuthPolicy::ChatGptAuth,
Some(&server.uri()),
/*forced_chatgpt_workspace_id*/ None,
/*auth_route_config*/ None,
SessionSource::Cli,
)
.await
@@ -717,6 +737,7 @@ async fn agent_identity_jwt_task_registration_retry_exhaustion_is_strict() -> an
&agent_identity,
Some(&chatgpt_base_url),
&authapi_base_url,
/*auth_route_config*/ None,
)
.await
.expect_err("agent identity jwt task retry exhaustion should fail");
@@ -747,6 +768,7 @@ async fn login_with_access_token_rejects_unsigned_jwt() {
/*forced_chatgpt_workspace_id*/ None,
Some(&chatgpt_base_url),
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await
.expect_err("unsigned access token should fail");
@@ -768,6 +790,7 @@ async fn missing_auth_json_returns_none() {
AuthCredentialsStoreMode::File,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await
.expect("call should succeed");
@@ -797,6 +820,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() {
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await
.unwrap()
@@ -858,6 +882,7 @@ async fn loads_api_key_from_auth_json() {
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await
.unwrap()
@@ -908,6 +933,7 @@ async fn unauthorized_recovery_reports_mode_and_step_names() {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
let managed = UnauthorizedRecovery {
@@ -952,6 +978,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() {
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await
.expect("load auth")
@@ -972,6 +999,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() {
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await
.expect("updated auth should parse");
@@ -1277,6 +1305,7 @@ async fn build_config(
forced_login_method,
forced_chatgpt_workspace_id,
chatgpt_base_url: None,
auth_route_config: None,
}
}
@@ -1359,6 +1388,7 @@ async fn load_auth_reads_access_token_from_env() {
Some(&chatgpt_base_url),
AuthKeyringBackendKind::Direct,
Some(&authapi_base_url),
/*auth_route_config*/ None,
)
.await
.expect("env auth should load")
@@ -1406,6 +1436,7 @@ async fn load_auth_reads_personal_access_token_from_env() {
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await
.expect("env auth should load")
@@ -1459,6 +1490,7 @@ async fn auth_manager_rejects_env_personal_access_token_workspace_mismatch() {
Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
@@ -1498,6 +1530,7 @@ async fn auth_manager_rejects_stored_personal_access_token_workspace_mismatch()
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await
.expect("personal access token login should succeed");
@@ -1509,6 +1542,7 @@ async fn auth_manager_rejects_stored_personal_access_token_workspace_mismatch()
Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
@@ -1542,6 +1576,7 @@ async fn personal_access_token_does_not_offer_unauthorized_recovery() {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await,
);
@@ -1574,6 +1609,7 @@ async fn load_auth_keeps_codex_api_key_env_precedence() {
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await
.expect("env auth should load")
@@ -1670,6 +1706,7 @@ async fn enforce_login_restrictions_logs_out_for_personal_access_token_workspace
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await
.expect("personal access token login should succeed");
@@ -1681,6 +1718,7 @@ async fn enforce_login_restrictions_logs_out_for_personal_access_token_workspace
forced_login_method: None,
forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
chatgpt_base_url: None,
auth_route_config: None,
};
let err = super::enforce_login_restrictions(&config)
@@ -1804,6 +1842,7 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat
forced_login_method: None,
forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
chatgpt_base_url: Some(chatgpt_base_url),
auth_route_config: None,
};
let err = super::enforce_login_restrictions_with_agent_identity_authapi_base_url(
@@ -2056,6 +2095,7 @@ async fn assert_agent_identity_plan_alias(
&jwt,
Some(&chatgpt_base_url),
&authapi_base_url,
/*auth_route_config*/ None,
)
.await
.expect("agent identity auth");
@@ -2087,6 +2127,7 @@ async fn plan_type_maps_known_plan() {
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await
.expect("load auth")
@@ -2118,6 +2159,7 @@ async fn plan_type_maps_self_serve_business_usage_based_plan() {
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await
.expect("load auth")
@@ -2152,6 +2194,7 @@ async fn plan_type_maps_enterprise_cbp_usage_based_plan() {
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await
.expect("load auth")
@@ -2186,6 +2229,7 @@ async fn plan_type_maps_unknown_to_unknown() {
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await
.expect("load auth")
@@ -2217,6 +2261,7 @@ async fn missing_plan_type_maps_to_unknown() {
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
/*agent_identity_authapi_base_url*/ None,
/*auth_route_config*/ None,
)
.await
.expect("load auth")
@@ -63,6 +63,7 @@ async fn login_with_bedrock_api_key_replaces_openai_auth() -> anyhow::Result<()>
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
@@ -111,6 +112,7 @@ async fn logout_removes_bedrock_auth() -> anyhow::Result<()> {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
@@ -135,6 +137,7 @@ async fn bedrock_only_auth_storage_creates_primary_auth() -> anyhow::Result<()>
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
+59 -2
View File
@@ -5,8 +5,11 @@
//! workspace.
use codex_client::BuildCustomCaTransportError;
use codex_client::BuildRouteAwareHttpClientError;
use codex_client::ClientRouteClass;
use codex_client::CodexHttpClient;
pub use codex_client::CodexRequestBuilder;
use codex_client::build_reqwest_client_for_route;
use codex_client::build_reqwest_client_with_custom_ca;
use codex_client::with_chatgpt_cloudflare_cookie_store;
use codex_terminal_detection::user_agent;
@@ -17,6 +20,8 @@ use std::sync::LazyLock;
use std::sync::Mutex;
use std::sync::RwLock;
use crate::outbound_proxy::AuthRouteConfig;
/// Set this to add a suffix to the User-Agent string.
///
/// It is not ideal that we're using a global singleton for this.
@@ -189,6 +194,9 @@ fn sanitize_user_agent(candidate: String, fallback: &str) -> String {
}
/// Create an HTTP client with default `originator` and `User-Agent` headers set.
///
/// This supported default path preserves reqwest's existing proxy behavior and does not opt into
/// Codex's route-aware system/PAC resolution.
pub fn create_client() -> CodexHttpClient {
let inner = build_reqwest_client();
CodexHttpClient::new(inner)
@@ -200,6 +208,10 @@ pub fn create_client() -> CodexHttpClient {
/// policy, then layers in shared custom CA handling from `CODEX_CA_CERTIFICATE` /
/// `SSL_CERT_FILE`. The function remains infallible for compatibility with existing call sites, so
/// a custom-CA or builder failure is logged and falls back to `reqwest::Client::new()`.
///
/// This supported default path preserves reqwest's existing proxy behavior and does not opt into
/// Codex's route-aware system/PAC resolution. Auth callers with route settings must use
/// `build_default_auth_reqwest_client` or `create_default_auth_client`.
pub fn build_reqwest_client() -> reqwest::Client {
try_build_reqwest_client().unwrap_or_else(|error| {
tracing::warn!(error = %error, "failed to build default reqwest client");
@@ -220,13 +232,58 @@ pub fn build_reqwest_client() -> reqwest::Client {
/// Callers that need a structured CA-loading failure instead of the legacy logged fallback can use
/// this method directly.
pub fn try_build_reqwest_client() -> Result<reqwest::Client, BuildCustomCaTransportError> {
build_reqwest_client_with_custom_ca(default_reqwest_client_builder())
}
fn default_reqwest_client_builder() -> reqwest::ClientBuilder {
let mut builder = reqwest::Client::builder().default_headers(default_headers());
if is_sandboxed() {
builder = builder.no_proxy();
}
builder = with_chatgpt_cloudflare_cookie_store(builder);
with_chatgpt_cloudflare_cookie_store(builder)
}
build_reqwest_client_with_custom_ca(builder)
/// Builds a raw reqwest client for an auth endpoint without Codex default headers.
pub(crate) fn build_raw_auth_reqwest_client(
endpoint: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> Result<reqwest::Client, BuildRouteAwareHttpClientError> {
build_reqwest_client_for_route(
reqwest::Client::builder(),
endpoint,
ClientRouteClass::Auth,
auth_route_config.map(AuthRouteConfig::route_config),
)
}
/// Builds the default Codex reqwest client for an auth endpoint.
pub(crate) fn build_default_auth_reqwest_client(
endpoint: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> Result<reqwest::Client, BuildRouteAwareHttpClientError> {
let Some(route_config) = auth_route_config.map(AuthRouteConfig::route_config) else {
return Ok(build_reqwest_client());
};
if is_sandboxed() {
// Preserve the sandbox's existing no-proxy policy; sandboxed command egress is routed
// separately through network-proxy.
return Ok(build_reqwest_client());
}
build_reqwest_client_for_route(
default_reqwest_client_builder(),
endpoint,
ClientRouteClass::Auth,
Some(route_config),
)
}
/// Builds the default Codex HTTP client wrapper for an auth endpoint.
pub(crate) fn create_default_auth_client(
endpoint: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> Result<CodexHttpClient, BuildRouteAwareHttpClientError> {
build_default_auth_reqwest_client(endpoint, auth_route_config).map(CodexHttpClient::new)
}
pub fn default_headers() -> HeaderMap {
+77 -17
View File
@@ -49,6 +49,8 @@ use crate::auth::storage::AuthStorageBackend;
use crate::auth::storage::create_auth_storage;
use crate::auth::util::try_parse_error_message;
use crate::default_client::create_client;
use crate::default_client::create_default_auth_client;
use crate::outbound_proxy::AuthRouteConfig;
use crate::token_data::TokenData;
use crate::token_data::parse_chatgpt_jwt_claims;
use crate::token_data::parse_jwt_expiration;
@@ -237,6 +239,7 @@ impl CodexAuth {
chatgpt_base_url: Option<&str>,
keyring_backend_kind: AuthKeyringBackendKind,
agent_identity_authapi_base_url: Option<&str>,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<Self> {
let auth_mode = auth_dot_json.resolved_mode();
if auth_mode == ApiAuthMode::ApiKey {
@@ -263,14 +266,18 @@ impl CodexAuth {
&jwt,
&base_url,
agent_identity_authapi_base_url,
auth_route_config,
)
.await?;
return Ok(Self::AgentIdentity(auth));
}
AgentIdentityStorage::Record(record) => {
let auth =
AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url)
.await?;
let auth = AgentIdentityAuth::from_record(
record,
agent_identity_authapi_base_url,
auth_route_config,
)
.await?;
return Ok(Self::AgentIdentity(auth));
}
}
@@ -281,7 +288,8 @@ impl CodexAuth {
"personal access token auth is missing a personal access token.",
));
};
return Self::from_personal_access_token(personal_access_token).await;
return Self::from_personal_access_token(personal_access_token, auth_route_config)
.await;
}
if auth_mode == ApiAuthMode::BedrockApiKey {
let Some(auth) = auth_dot_json.bedrock_api_key else {
@@ -293,9 +301,10 @@ impl CodexAuth {
}
let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode);
let client = create_default_auth_client(&refresh_token_endpoint(), auth_route_config)?;
let state = ChatgptAuthState {
auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))),
client: create_client(),
client,
};
match auth_mode {
@@ -324,6 +333,7 @@ impl CodexAuth {
auth_credentials_store_mode: AuthCredentialsStoreMode,
chatgpt_base_url: Option<&str>,
keyring_backend_kind: AuthKeyringBackendKind,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<Option<Self>> {
let agent_identity_authapi_base_url =
agent_identity_authapi_base_url(chatgpt_base_url).ok();
@@ -335,6 +345,7 @@ impl CodexAuth {
chatgpt_base_url,
keyring_backend_kind,
agent_identity_authapi_base_url.as_deref(),
auth_route_config,
)
.await
}
@@ -342,12 +353,14 @@ impl CodexAuth {
pub async fn from_agent_identity_jwt(
jwt: &str,
chatgpt_base_url: Option<&str>,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<Self> {
let agent_identity_authapi_base_url = agent_identity_authapi_base_url(chatgpt_base_url)?;
Self::from_agent_identity_jwt_with_authapi_base_url(
jwt,
chatgpt_base_url,
&agent_identity_authapi_base_url,
auth_route_config,
)
.await
}
@@ -356,19 +369,29 @@ impl CodexAuth {
jwt: &str,
chatgpt_base_url: Option<&str>,
agent_identity_authapi_base_url: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<Self> {
let base_url = chatgpt_base_url
.unwrap_or(ChatGptEnvironment::default().chatgpt_base_url())
.trim_end_matches('/')
.to_string();
Ok(Self::AgentIdentity(
AgentIdentityAuth::from_jwt(jwt, &base_url, agent_identity_authapi_base_url).await?,
AgentIdentityAuth::from_jwt(
jwt,
&base_url,
agent_identity_authapi_base_url,
auth_route_config,
)
.await?,
))
}
pub async fn from_personal_access_token(access_token: &str) -> std::io::Result<Self> {
pub async fn from_personal_access_token(
access_token: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<Self> {
Ok(Self::PersonalAccessToken(
PersonalAccessTokenAuth::load(access_token).await?,
PersonalAccessTokenAuth::load(access_token, auth_route_config).await?,
))
}
@@ -568,6 +591,7 @@ impl CodexAuth {
policy: AgentIdentityAuthPolicy,
agent_identity_authapi_base_url: Option<&str>,
forced_chatgpt_workspace_id: Option<Vec<String>>,
auth_route_config: Option<&AuthRouteConfig>,
session_source: SessionSource,
) -> std::io::Result<Option<AgentIdentityAuth>> {
match self {
@@ -583,6 +607,7 @@ impl CodexAuth {
self.ensure_managed_chatgpt_agent_identity(
require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?,
forced_chatgpt_workspace_id,
auth_route_config,
session_source,
)
.await
@@ -595,6 +620,7 @@ impl CodexAuth {
&self,
agent_identity_authapi_base_url: &str,
forced_chatgpt_workspace_id: Option<Vec<String>>,
auth_route_config: Option<&AuthRouteConfig>,
session_source: SessionSource,
) -> std::io::Result<AgentIdentityAuth> {
let binding =
@@ -607,9 +633,13 @@ impl CodexAuth {
&& record_matches_managed_chatgpt_binding(&record, &binding)
{
let should_persist = record_needs_task_registration(&record);
let auth = AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url)
.await
.map_err(|err| classify_bootstrap_error("agent task registration", err))?;
let auth = AgentIdentityAuth::from_record(
record,
agent_identity_authapi_base_url,
auth_route_config,
)
.await
.map_err(|err| classify_bootstrap_error("agent task registration", err))?;
if should_persist {
self.persist_managed_chatgpt_agent_identity_record(auth.record().clone())?;
}
@@ -620,6 +650,7 @@ impl CodexAuth {
binding,
agent_identity_authapi_base_url,
session_source,
auth_route_config,
)
.await?;
self.persist_managed_chatgpt_agent_identity_record(auth.record().clone())?;
@@ -789,6 +820,7 @@ pub async fn logout_with_revoke(
codex_home: &Path,
auth_credentials_store_mode: AuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<bool> {
let auth_dot_json = match load_auth_dot_json(
codex_home,
@@ -801,7 +833,7 @@ pub async fn logout_with_revoke(
None
}
};
if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref()).await {
if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref(), auth_route_config).await {
tracing::warn!("failed to revoke auth tokens during logout: {err}");
}
logout_all_stores(
@@ -843,10 +875,11 @@ pub async fn login_with_access_token(
forced_chatgpt_workspace_id: Option<&[String]>,
chatgpt_base_url: Option<&str>,
keyring_backend_kind: AuthKeyringBackendKind,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<()> {
let auth_dot_json = match classify_codex_access_token(access_token) {
CodexAccessToken::PersonalAccessToken(access_token) => {
let auth = PersonalAccessTokenAuth::load(access_token).await?;
let auth = PersonalAccessTokenAuth::load(access_token, auth_route_config).await?;
ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, &auth)?;
AuthDotJson {
// Infer PAT auth from the credential field so older Codex builds can still
@@ -865,7 +898,7 @@ pub async fn login_with_access_token(
.unwrap_or(ChatGptEnvironment::default().chatgpt_base_url())
.trim_end_matches('/')
.to_string();
verified_record_from_jwt(jwt, &base_url).await?;
verified_record_from_jwt(jwt, &base_url, auth_route_config).await?;
AuthDotJson {
auth_mode: Some(ApiAuthMode::AgentIdentity),
openai_api_key: None,
@@ -954,8 +987,10 @@ pub struct AuthConfig {
pub forced_login_method: Option<ForcedLoginMethod>,
pub chatgpt_base_url: Option<String>,
pub forced_chatgpt_workspace_id: Option<Vec<String>>,
pub auth_route_config: Option<AuthRouteConfig>,
}
/// Enforces configured login restrictions using auth-owned HTTP settings.
pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> {
let agent_identity_authapi_base_url =
agent_identity_authapi_base_url(config.chatgpt_base_url.as_deref()).ok();
@@ -978,6 +1013,7 @@ async fn enforce_login_restrictions_with_agent_identity_authapi_base_url(
config.chatgpt_base_url.as_deref(),
config.keyring_backend_kind,
agent_identity_authapi_base_url,
config.auth_route_config.as_ref(),
)
.await?
else {
@@ -1113,6 +1149,7 @@ fn logout_all_stores(
Ok(removed_ephemeral || removed_managed)
}
#[allow(clippy::too_many_arguments)]
async fn load_auth(
codex_home: &Path,
enable_codex_api_key_env: bool,
@@ -1121,6 +1158,7 @@ async fn load_auth(
chatgpt_base_url: Option<&str>,
keyring_backend_kind: AuthKeyringBackendKind,
agent_identity_authapi_base_url: Option<&str>,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<Option<CodexAuth>> {
// 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() {
@@ -1142,6 +1180,7 @@ async fn load_auth(
chatgpt_base_url,
keyring_backend_kind,
agent_identity_authapi_base_url,
auth_route_config,
)
.await?;
if let CodexAuth::PersonalAccessToken(auth) = &auth {
@@ -1153,7 +1192,7 @@ async fn load_auth(
if let Some(access_token) = read_codex_access_token_from_env() {
return match classify_codex_access_token(&access_token) {
CodexAccessToken::PersonalAccessToken(access_token) => {
let auth = PersonalAccessTokenAuth::load(access_token).await?;
let auth = PersonalAccessTokenAuth::load(access_token, auth_route_config).await?;
ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, &auth)?;
Ok(Some(CodexAuth::PersonalAccessToken(auth)))
}
@@ -1162,6 +1201,7 @@ async fn load_auth(
jwt,
chatgpt_base_url,
require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?,
auth_route_config,
)
}
.await
@@ -1192,6 +1232,7 @@ async fn load_auth(
chatgpt_base_url,
keyring_backend_kind,
agent_identity_authapi_base_url,
auth_route_config,
)
.await?;
if let CodexAuth::PersonalAccessToken(auth) = &auth {
@@ -1237,7 +1278,6 @@ async fn request_chatgpt_token_refresh(
grant_type: "refresh_token",
refresh_token,
};
let endpoint = refresh_token_endpoint();
// Use shared client factory to include standard headers
@@ -1702,6 +1742,7 @@ pub struct AuthManager {
refresh_lock: Semaphore,
agent_identity_lock: Semaphore,
external_auth: RwLock<Option<Arc<dyn ExternalAuth>>>,
auth_route_config: Option<AuthRouteConfig>,
}
/// Configuration view required to construct a shared [`AuthManager`].
@@ -1725,6 +1766,9 @@ pub trait AuthManagerConfig {
/// Returns the ChatGPT backend base URL used for first-party backend authorization.
fn chatgpt_base_url(&self) -> String;
/// Returns route-selection settings for auth-owned clients.
fn auth_route_config(&self) -> Option<AuthRouteConfig>;
}
impl Debug for AuthManager {
@@ -1743,6 +1787,7 @@ impl Debug for AuthManager {
&self.forced_chatgpt_workspace_id,
)
.field("chatgpt_base_url", &self.chatgpt_base_url)
.field("auth_route_config", &self.auth_route_config)
.field("has_external_auth", &self.has_external_auth())
.finish_non_exhaustive()
}
@@ -1764,6 +1809,7 @@ impl AuthManager {
forced_chatgpt_workspace_id: Option<Vec<String>>,
chatgpt_base_url: Option<String>,
keyring_backend_kind: AuthKeyringBackendKind,
auth_route_config: Option<AuthRouteConfig>,
) -> Self {
let agent_identity_authapi_base_url =
agent_identity_authapi_base_url(chatgpt_base_url.as_deref()).ok();
@@ -1775,6 +1821,7 @@ impl AuthManager {
chatgpt_base_url.as_deref(),
keyring_backend_kind,
agent_identity_authapi_base_url.as_deref(),
auth_route_config.as_ref(),
)
.await
.ok()
@@ -1796,6 +1843,7 @@ impl AuthManager {
refresh_lock: Semaphore::new(/*permits*/ 1),
agent_identity_lock: Semaphore::new(/*permits*/ 1),
external_auth: RwLock::new(None),
auth_route_config,
}
}
@@ -1820,6 +1868,7 @@ impl AuthManager {
refresh_lock: Semaphore::new(/*permits*/ 1),
agent_identity_lock: Semaphore::new(/*permits*/ 1),
external_auth: RwLock::new(None),
auth_route_config: None,
})
}
@@ -1843,6 +1892,7 @@ impl AuthManager {
refresh_lock: Semaphore::new(/*permits*/ 1),
agent_identity_lock: Semaphore::new(/*permits*/ 1),
external_auth: RwLock::new(None),
auth_route_config: None,
})
}
@@ -1874,6 +1924,7 @@ impl AuthManager {
refresh_lock: Semaphore::new(/*permits*/ 1),
agent_identity_lock: Semaphore::new(/*permits*/ 1),
external_auth: RwLock::new(None),
auth_route_config: None,
})
}
@@ -1897,6 +1948,7 @@ impl AuthManager {
external_auth: RwLock::new(Some(
Arc::new(BearerTokenRefresher::new(config)) as Arc<dyn ExternalAuth>
)),
auth_route_config: None,
})
}
@@ -1958,6 +2010,7 @@ impl AuthManager {
policy,
self.agent_identity_authapi_base_url.as_deref(),
self.forced_chatgpt_workspace_id(),
self.auth_route_config.as_ref(),
session_source,
)
.await;
@@ -1966,6 +2019,7 @@ impl AuthManager {
policy,
self.agent_identity_authapi_base_url.as_deref(),
self.forced_chatgpt_workspace_id(),
self.auth_route_config.as_ref(),
session_source,
)
.await
@@ -2074,6 +2128,7 @@ impl AuthManager {
self.chatgpt_base_url.as_deref(),
self.keyring_backend_kind,
self.agent_identity_authapi_base_url.as_deref(),
self.auth_route_config.as_ref(),
)
.await
.ok()
@@ -2149,6 +2204,7 @@ impl AuthManager {
forced_chatgpt_workspace_id: Option<Vec<String>>,
chatgpt_base_url: Option<String>,
keyring_backend_kind: AuthKeyringBackendKind,
auth_route_config: Option<AuthRouteConfig>,
) -> Arc<Self> {
Arc::new(
Self::new(
@@ -2158,6 +2214,7 @@ impl AuthManager {
forced_chatgpt_workspace_id,
chatgpt_base_url,
keyring_backend_kind,
auth_route_config,
)
.await,
)
@@ -2175,6 +2232,7 @@ impl AuthManager {
config.forced_chatgpt_workspace_id(),
Some(config.chatgpt_base_url()),
config.auth_keyring_backend_kind(),
config.auth_route_config(),
)
.await
}
@@ -2328,7 +2386,9 @@ impl AuthManager {
let auth_dot_json = self
.auth_cached()
.and_then(|auth| auth.get_current_auth_json());
if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref()).await {
if let Err(err) =
revoke_auth_tokens(auth_dot_json.as_ref(), self.auth_route_config.as_ref()).await
{
tracing::warn!("failed to revoke auth tokens during logout: {err}");
}
let result = logout_all_stores(
@@ -5,7 +5,8 @@ use serde::Deserialize;
use std::env;
use std::fmt;
use crate::default_client::create_client;
use crate::default_client::create_default_auth_client;
use crate::outbound_proxy::AuthRouteConfig;
const PROD_AUTHAPI_BASE_URL: &str = "https://auth.openai.com/api/accounts";
const CODEX_AUTHAPI_BASE_URL_ENV_VAR: &str = "CODEX_AUTHAPI_BASE_URL";
@@ -36,13 +37,18 @@ impl fmt::Debug for PersonalAccessTokenAuth {
}
impl PersonalAccessTokenAuth {
pub(super) async fn load(access_token: &str) -> std::io::Result<Self> {
pub(super) async fn load(
access_token: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<Self> {
let authapi_base_url = env::var(CODEX_AUTHAPI_BASE_URL_ENV_VAR)
.ok()
.map(|base_url| base_url.trim().trim_end_matches('/').to_string())
.filter(|base_url| !base_url.is_empty())
.unwrap_or_else(|| PROD_AUTHAPI_BASE_URL.to_string());
hydrate_personal_access_token(&create_client(), &authapi_base_url, access_token).await
let endpoint = whoami_endpoint(&authapi_base_url);
let client = create_default_auth_client(&endpoint, auth_route_config)?;
hydrate_personal_access_token(&client, &endpoint, access_token).await
}
pub fn access_token(&self) -> &str {
@@ -72,12 +78,11 @@ impl PersonalAccessTokenAuth {
async fn hydrate_personal_access_token(
client: &CodexHttpClient,
authapi_base_url: &str,
endpoint: &str,
access_token: &str,
) -> std::io::Result<PersonalAccessTokenAuth> {
let endpoint = format!("{}{WHOAMI_PATH}", authapi_base_url.trim_end_matches('/'));
let response = client
.get(&endpoint)
.get(endpoint)
.bearer_auth(access_token)
.send()
.await
@@ -107,6 +112,10 @@ async fn hydrate_personal_access_token(
})
}
fn whoami_endpoint(authapi_base_url: &str) -> String {
format!("{}{WHOAMI_PATH}", authapi_base_url.trim_end_matches('/'))
}
#[cfg(test)]
#[path = "personal_access_token_tests.rs"]
mod tests;
@@ -1,4 +1,5 @@
use super::*;
use crate::default_client::create_client;
use pretty_assertions::assert_eq;
use serde_json::json;
use wiremock::Mock;
@@ -29,7 +30,8 @@ async fn hydrate_sends_bearer_token_and_preserves_metadata() {
.mount(&server)
.await;
let auth = hydrate_personal_access_token(&create_client(), &server.uri(), "at-example")
let endpoint = whoami_endpoint(&server.uri());
let auth = hydrate_personal_access_token(&create_client(), &endpoint, "at-example")
.await
.expect("personal access token hydration should succeed");
@@ -59,7 +61,8 @@ async fn hydrate_rejects_missing_email() {
.mount(&server)
.await;
let err = hydrate_personal_access_token(&create_client(), &server.uri(), "at-example")
let endpoint = whoami_endpoint(&server.uri());
let err = hydrate_personal_access_token(&create_client(), &endpoint, "at-example")
.await
.expect_err("personal access token hydration should reject missing email");
+4 -2
View File
@@ -16,7 +16,8 @@ use super::manager::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR;
use super::manager::oauth_client_id;
use super::storage::AuthDotJson;
use super::util::try_parse_error_message;
use crate::default_client::create_client;
use crate::default_client::create_default_auth_client;
use crate::outbound_proxy::AuthRouteConfig;
use crate::token_data::TokenData;
const REVOKE_HTTP_TIMEOUT: Duration = Duration::from_secs(10);
@@ -53,13 +54,14 @@ struct RevokeTokenRequest<'a> {
pub(super) async fn revoke_auth_tokens(
auth_dot_json: Option<&AuthDotJson>,
auth_route_config: Option<&AuthRouteConfig>,
) -> Result<(), std::io::Error> {
let Some((token, kind)) = auth_dot_json.and_then(revocable_token) else {
return Ok(());
};
let client = create_client();
let endpoint = revoke_token_endpoint();
let client = create_default_auth_client(&endpoint, auth_route_config)?;
revoke_oauth_token(&client, endpoint.as_str(), token, kind, REVOKE_HTTP_TIMEOUT).await
}
+6 -3
View File
@@ -6,9 +6,9 @@ use serde::de::{self};
use std::time::Duration;
use std::time::Instant;
use crate::default_client::build_raw_auth_reqwest_client;
use crate::pkce::PkceCodes;
use crate::server::ServerOptions;
use codex_client::build_reqwest_client_with_custom_ca;
use std::io;
const ANSI_BLUE: &str = "\x1b[94m";
@@ -157,8 +157,10 @@ fn print_device_code_prompt(verification_url: &str, code: &str) {
}
pub async fn request_device_code(opts: &ServerOptions) -> std::io::Result<DeviceCode> {
let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?;
let base_url = opts.issuer.trim_end_matches('/');
// The route selected for the issuer is reused for all device-auth endpoint paths; the endpoint
// paths are not resolved separately.
let client = build_raw_auth_reqwest_client(base_url, opts.auth_route_config.as_ref())?;
let api_base_url = format!("{base_url}/api/accounts");
let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?;
@@ -174,8 +176,8 @@ pub async fn complete_device_code_login(
opts: ServerOptions,
device_code: DeviceCode,
) -> std::io::Result<()> {
let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?;
let base_url = opts.issuer.trim_end_matches('/');
let client = build_raw_auth_reqwest_client(base_url, opts.auth_route_config.as_ref())?;
let api_base_url = format!("{base_url}/api/accounts");
let code_resp = poll_for_token(
@@ -199,6 +201,7 @@ pub async fn complete_device_code_login(
&redirect_uri,
&pkce,
&code_resp.authorization_code,
opts.auth_route_config.as_ref(),
)
.await
.map_err(|err| std::io::Error::other(format!("device code exchange failed: {err}")))?;
+2
View File
@@ -3,6 +3,7 @@ pub mod auth_env_telemetry;
pub mod token_data;
mod device_code_auth;
mod outbound_proxy;
mod pkce;
mod server;
@@ -53,4 +54,5 @@ pub use auth::read_openai_api_key_from_env;
pub use auth::save_auth;
pub use auth_env_telemetry::AuthEnvTelemetry;
pub use auth_env_telemetry::collect_auth_env_telemetry;
pub use outbound_proxy::AuthRouteConfig;
pub use token_data::TokenData;
+22
View File
@@ -0,0 +1,22 @@
use codex_client::OutboundProxyConfig;
/// Auth-layer adapter around client-owned proxy policy.
///
/// `AuthConfig` carries this value while endpoint resolution and platform details remain in the
/// client layer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthRouteConfig {
route_config: OutboundProxyConfig,
}
impl AuthRouteConfig {
pub fn respect_system_proxy() -> Self {
Self {
route_config: OutboundProxyConfig::respect_system_proxy(),
}
}
pub(crate) fn route_config(&self) -> &OutboundProxyConfig {
&self.route_config
}
}
+28 -8
View File
@@ -27,7 +27,9 @@ use std::time::Duration;
use crate::auth::AuthDotJson;
use crate::auth::AuthKeyringBackendKind;
use crate::auth::save_auth;
use crate::default_client::build_raw_auth_reqwest_client;
use crate::default_client::originator;
use crate::outbound_proxy::AuthRouteConfig;
use crate::pkce::PkceCodes;
use crate::pkce::generate_pkce;
use crate::token_data::TokenData;
@@ -35,7 +37,6 @@ use crate::token_data::parse_chatgpt_jwt_claims;
use base64::Engine;
use chrono::Utc;
use codex_app_server_protocol::AuthMode;
use codex_client::build_reqwest_client_with_custom_ca;
use codex_config::types::AuthCredentialsStoreMode;
use codex_utils_template::Template;
use rand::RngCore;
@@ -71,6 +72,7 @@ pub struct ServerOptions {
pub codex_streamlined_login: bool,
pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
pub auth_keyring_backend_kind: AuthKeyringBackendKind,
pub auth_route_config: Option<AuthRouteConfig>,
}
impl ServerOptions {
@@ -81,6 +83,7 @@ impl ServerOptions {
forced_chatgpt_workspace_id: Option<Vec<String>>,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
auth_keyring_backend_kind: AuthKeyringBackendKind,
auth_route_config: Option<AuthRouteConfig>,
) -> Self {
Self {
codex_home,
@@ -93,6 +96,7 @@ impl ServerOptions {
codex_streamlined_login: false,
cli_auth_credentials_store_mode,
auth_keyring_backend_kind,
auth_route_config,
}
}
}
@@ -336,8 +340,15 @@ async fn process_request(
}
};
match exchange_code_for_tokens(&opts.issuer, &opts.client_id, redirect_uri, pkce, &code)
.await
match exchange_code_for_tokens(
&opts.issuer,
&opts.client_id,
redirect_uri,
pkce,
&code,
opts.auth_route_config.as_ref(),
)
.await
{
Ok(tokens) => {
if let Err(message) = ensure_workspace_allowed(
@@ -353,9 +364,14 @@ async fn process_request(
);
}
// Obtain API key via token-exchange and persist
let api_key = obtain_api_key(&opts.issuer, &opts.client_id, &tokens.id_token)
.await
.ok();
let api_key = obtain_api_key(
&opts.issuer,
&opts.client_id,
&tokens.id_token,
opts.auth_route_config.as_ref(),
)
.await
.ok();
if let Err(err) = persist_tokens_async(
&opts.codex_home,
api_key.clone(),
@@ -719,6 +735,7 @@ pub(crate) async fn exchange_code_for_tokens(
redirect_uri: &str,
pkce: &PkceCodes,
code: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> io::Result<ExchangedTokens> {
#[derive(serde::Deserialize)]
struct TokenResponse {
@@ -727,7 +744,9 @@ pub(crate) async fn exchange_code_for_tokens(
refresh_token: String,
}
let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?;
// The route selected for the issuer is reused for token exchange; the token endpoint path is
// not resolved separately.
let client = build_raw_auth_reqwest_client(issuer.trim_end_matches('/'), auth_route_config)?;
let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/'));
info!(
issuer = %sanitize_url_for_logging(issuer),
@@ -1129,14 +1148,15 @@ pub(crate) async fn obtain_api_key(
issuer: &str,
client_id: &str,
id_token: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> io::Result<String> {
// Token exchange for an API key access token
#[derive(serde::Deserialize)]
struct ExchangeResp {
access_token: String,
}
let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?;
let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/'));
let client = build_raw_auth_reqwest_client(&token_endpoint, auth_route_config)?;
let resp = client
.post(token_endpoint)
.header("Content-Type", "application/x-www-form-urlencoded")
@@ -1220,6 +1220,7 @@ impl RefreshTokenTestContext {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
@@ -112,6 +112,7 @@ fn server_opts(
/*forced_chatgpt_workspace_id*/ None,
cli_auth_credentials_store_mode,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
);
opts.issuer = issuer;
opts.open_browser = false;
@@ -277,6 +278,7 @@ async fn device_code_login_integration_persists_without_api_key_on_exchange_fail
/*forced_chatgpt_workspace_id*/ None,
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
);
opts.issuer = issuer;
opts.open_browser = false;
@@ -332,6 +334,7 @@ async fn device_code_login_integration_handles_error_payload() -> anyhow::Result
/*forced_chatgpt_workspace_id*/ None,
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
);
opts.issuer = issuer;
opts.open_browser = false;
@@ -122,6 +122,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> {
let opts = ServerOptions {
codex_home: server_home,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
auth_route_config: None,
client_id: codex_login::CLIENT_ID.to_string(),
issuer,
port: 0,
@@ -185,6 +186,7 @@ async fn creates_missing_codex_home_dir() -> Result<()> {
let opts = ServerOptions {
codex_home: server_home,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
auth_route_config: None,
client_id: codex_login::CLIENT_ID.to_string(),
issuer,
port: 0,
@@ -226,6 +228,7 @@ async fn login_server_includes_forced_workspaces_as_one_query_param() -> Result<
let opts = ServerOptions {
codex_home,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
auth_route_config: None,
client_id: codex_login::CLIENT_ID.to_string(),
issuer,
port: 0,
@@ -268,6 +271,7 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> {
let opts = ServerOptions {
codex_home: codex_home.clone(),
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
auth_route_config: None,
client_id: codex_login::CLIENT_ID.to_string(),
issuer,
port: 0,
@@ -329,6 +333,7 @@ async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error()
let opts = ServerOptions {
codex_home: codex_home.clone(),
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
auth_route_config: None,
client_id: codex_login::CLIENT_ID.to_string(),
issuer,
port: 0,
@@ -398,6 +403,7 @@ async fn oauth_access_denied_unknown_reason_uses_generic_error_page() -> Result<
let opts = ServerOptions {
codex_home: codex_home.clone(),
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
auth_route_config: None,
client_id: codex_login::CLIENT_ID.to_string(),
issuer,
port: 0,
@@ -507,6 +513,7 @@ async fn falls_back_to_registered_fallback_port_when_default_port_is_in_use() ->
/*forced_chatgpt_workspace_id*/ None,
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
);
opts.issuer = issuer;
opts.open_browser = false;
@@ -545,6 +552,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> {
let first_opts = ServerOptions {
codex_home: first_codex_home,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
auth_route_config: None,
client_id: codex_login::CLIENT_ID.to_string(),
issuer: issuer.clone(),
port: 0,
@@ -567,6 +575,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> {
let second_opts = ServerOptions {
codex_home: second_codex_home,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
auth_route_config: None,
client_id: codex_login::CLIENT_ID.to_string(),
issuer,
port: login_port,
+4
View File
@@ -61,6 +61,7 @@ async fn logout_with_revoke_revokes_refresh_token_then_removes_auth() -> Result<
codex_home.path(),
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await?;
@@ -119,6 +120,7 @@ async fn logout_with_revoke_uses_stored_auth_when_access_token_env_is_set() -> R
codex_home.path(),
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await?;
@@ -161,6 +163,7 @@ async fn logout_with_revoke_removes_auth_when_revoke_fails() -> Result<()> {
codex_home.path(),
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await?;
@@ -204,6 +207,7 @@ async fn auth_manager_logout_with_revoke_uses_cached_auth() -> Result<()> {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
save_auth(