mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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:
1 parent
e48ab86693
commit
1659c4a629
37 files changed
+885
-54
No files matched your search
@@ -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(®istration_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?;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,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}")))?;
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user