diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 70902b4bd59..3fdead56d90 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1527,13 +1527,16 @@ name = "codex-login" version = "0.0.0" dependencies = [ "anyhow", + "assert_cmd", "base64", "chrono", "codex-app-server-protocol", "codex-core", "core_test_support", + "pretty_assertions", "rand 0.9.2", "reqwest", + "rustls-pki-types", "serde", "serde_json", "sha2", @@ -5666,9 +5669,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.12.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" dependencies = [ "web-time", "zeroize", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index d79e87acce2..b012fda82cb 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -181,6 +181,7 @@ regex = "1.12.2" regex-lite = "0.1.7" reqwest = "0.12" rmcp = { version = "0.12.0", default-features = false } +rustls-pki-types = "1.13.0" schemars = "0.8.22" seccompiler = "0.5.0" sentry = "0.46.0" diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index 94af9e19afb..671178249cb 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -14,6 +14,7 @@ codex-core = { workspace = true } codex-app-server-protocol = { workspace = true } rand = { workspace = true } reqwest = { workspace = true, features = ["json", "blocking"] } +rustls-pki-types = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } @@ -32,6 +33,8 @@ webbrowser = { workspace = true } [dev-dependencies] anyhow = { workspace = true } +assert_cmd = { workspace = true } core_test_support = { workspace = true } +pretty_assertions = { workspace = true } tempfile = { workspace = true } wiremock = { workspace = true } diff --git a/codex-rs/login/src/bin/login_ca_probe.rs b/codex-rs/login/src/bin/login_ca_probe.rs new file mode 100644 index 00000000000..43d51d4b4d4 --- /dev/null +++ b/codex-rs/login/src/bin/login_ca_probe.rs @@ -0,0 +1,23 @@ +//! Helper binary for exercising custom CA environment handling in tests. +//! +//! The login flows honor `CODEX_CA_CERTIFICATE` and `SSL_CERT_FILE`, but those +//! environment variables are process-global and unsafe to mutate in parallel +//! test execution. This probe keeps the behavior under test while letting +//! integration tests (`tests/ca_env.rs`) set env vars per-process, proving: +//! - env precedence is respected, +//! - multi-cert PEM bundles load, +//! - error messages guide users when CA files are invalid. + +use std::process; + +fn main() { + match codex_login::build_login_http_client() { + Ok(_) => { + println!("ok"); + } + Err(error) => { + eprintln!("{error}"); + process::exit(1); + } + } +} diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index d9e7d90ce28..5cf8f3013ca 100644 --- a/codex-rs/login/src/device_code_auth.rs +++ b/codex-rs/login/src/device_code_auth.rs @@ -8,6 +8,7 @@ use std::time::Instant; use crate::pkce::PkceCodes; use crate::server::ServerOptions; +use crate::server::build_login_http_client; use std::io; const ANSI_BLUE: &str = "\x1b[94m"; @@ -151,7 +152,7 @@ fn print_device_code_prompt(code: &str) { /// Full device code login flow. pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> { - let client = reqwest::Client::new(); + let client = build_login_http_client()?; let base_url = opts.issuer.trim_end_matches('/'); let api_base_url = format!("{}/api/accounts", opts.issuer.trim_end_matches('/')); let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?; diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index ac2cd28bea5..4002af5016d 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -6,6 +6,7 @@ pub use device_code_auth::run_device_code_login; pub use server::LoginServer; pub use server::ServerOptions; pub use server::ShutdownHandle; +pub use server::build_login_http_client; pub use server::run_login_server; // Re-export commonly used auth types and helpers from codex-core for compatibility diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 999c19072e5..6af7bded753 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -1,3 +1,5 @@ +use std::env; +use std::fs; use std::io::Cursor; use std::io::Read; use std::io::Write; @@ -21,6 +23,9 @@ use codex_core::default_client::originator; use codex_core::token_data::TokenData; use codex_core::token_data::parse_id_token; use rand::RngCore; +use rustls_pki_types::CertificateDer; +use rustls_pki_types::pem::PemObject; +use rustls_pki_types::pem::{self}; use serde_json::Value as JsonValue; use tiny_http::Header; use tiny_http::Request; @@ -30,6 +35,9 @@ use tiny_http::StatusCode; const DEFAULT_ISSUER: &str = "https://auth.openai.com"; const DEFAULT_PORT: u16 = 1455; +const CODEX_CA_CERT_ENV: &str = "CODEX_CA_CERTIFICATE"; +const SSL_CERT_FILE_ENV: &str = "SSL_CERT_FILE"; +const CA_CERT_HINT: &str = "If you set CODEX_CA_CERTIFICATE or SSL_CERT_FILE, ensure it points to a PEM file containing one or more CERTIFICATE blocks, or unset it to use system roots."; #[derive(Debug, Clone)] pub struct ServerOptions { @@ -491,6 +499,148 @@ pub(crate) struct ExchangedTokens { pub refresh_token: String, } +trait EnvSource { + fn var(&self, key: &str) -> Option; +} + +struct ProcessEnv; + +impl EnvSource for ProcessEnv { + fn var(&self, key: &str) -> Option { + env::var(key).ok() + } +} + +fn login_ca_certificate_path(env_source: &dyn EnvSource) -> Option { + env_source + .var(CODEX_CA_CERT_ENV) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| { + env_source + .var(SSL_CERT_FILE_ENV) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + }) +} + +fn strip_pem_block(input: &str, label: &str) -> String { + let begin = format!("-----BEGIN {label}-----"); + let end = format!("-----END {label}-----"); + + let mut output = String::new(); + let mut rest = input; + + loop { + if let Some(begin_idx) = rest.find(&begin) { + output.push_str(&rest[..begin_idx]); + if let Some(end_idx) = rest[begin_idx..].find(&end) { + let after_end = begin_idx + end_idx + end.len(); + rest = &rest[after_end..]; + continue; + } else { + output.push_str(&rest[begin_idx..]); + break; + } + } else { + output.push_str(rest); + break; + } + } + + output +} + +fn pem_parse_error(path: &Path, error: &pem::Error) -> io::Error { + let detail = match error { + pem::Error::NoItemsFound => "no certificates found in PEM file".to_string(), + _ => format!("failed to parse PEM file: {error}"), + }; + + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Failed to load CA certificates from {path}: {detail}. {hint}", + path = path.display(), + hint = CA_CERT_HINT + ), + ) +} + +fn read_ca_certificates(path: &Path) -> io::Result>> { + let pem_data = fs::read(path).map_err(|error| { + io::Error::new( + error.kind(), + format!( + "Failed to read CA certificate file {path}: {error}. {hint}", + path = path.display(), + hint = CA_CERT_HINT + ), + ) + })?; + + // Support both standard CERTIFICATE and TRUSTED CERTIFICATE labels by + // normalizing the latter to the former before parsing. + let mut normalized_pem = String::from_utf8(pem_data.clone()) + .map(|s| { + s.replace("BEGIN TRUSTED CERTIFICATE", "BEGIN CERTIFICATE") + .replace("END TRUSTED CERTIFICATE", "END CERTIFICATE") + }) + .unwrap_or_else(|_| String::from_utf8_lossy(&pem_data).into_owned()); + + // Strip CRL blocks so mixed bundles (certs + CRLs) do not fail parsing. + normalized_pem = strip_pem_block(&normalized_pem, "X509 CRL"); + + let mut certificates = Vec::new(); + for cert_result in + as PemObject>::pem_slice_iter(normalized_pem.as_bytes()) + { + let cert = cert_result.map_err(|error| pem_parse_error(path, &error))?; + certificates.push(cert); + } + + if certificates.is_empty() { + let error = pem::Error::NoItemsFound; + return Err(pem_parse_error(path, &error)); + } + + Ok(certificates) +} + +/// Custom CA handling for login flows. +/// +/// Enterprise TLS inspection proxies often rely on custom CA roots, which means +/// system roots alone cannot validate the OAuth exchange. We allow opt-in CA +/// overrides via `CODEX_CA_CERTIFICATE` (preferred) or `SSL_CERT_FILE`. +pub fn build_login_http_client() -> io::Result { + build_login_http_client_with_env(&ProcessEnv) +} + +fn build_login_http_client_with_env(env_source: &dyn EnvSource) -> io::Result { + let mut builder = reqwest::Client::builder(); + + if let Some(path) = login_ca_certificate_path(env_source) { + let certificates = read_ca_certificates(&path)?; + + for (idx, cert) in certificates.iter().enumerate() { + let certificate = reqwest::Certificate::from_der(cert.as_ref()).map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Failed to parse certificate #{index} from {path}: {error}. {hint}", + index = idx + 1, + path = path.display(), + hint = CA_CERT_HINT + ), + ) + })?; + builder = builder.add_root_certificate(certificate); + } + } + + builder.build().map_err(io::Error::other) +} + pub(crate) async fn exchange_code_for_tokens( issuer: &str, client_id: &str, @@ -505,7 +655,7 @@ pub(crate) async fn exchange_code_for_tokens( refresh_token: String, } - let client = reqwest::Client::new(); + let client = build_login_http_client()?; let resp = client .post(format!("{issuer}/oauth/token")) .header("Content-Type", "application/x-www-form-urlencoded") @@ -695,7 +845,7 @@ pub(crate) async fn obtain_api_key( struct ExchangeResp { access_token: String, } - let client = reqwest::Client::new(); + let client = build_login_http_client()?; let resp = client .post(format!("{issuer}/oauth/token")) .header("Content-Type", "application/x-www-form-urlencoded") @@ -719,3 +869,158 @@ pub(crate) async fn obtain_api_key( let body: ExchangeResp = resp.json().await.map_err(io::Error::other)?; Ok(body.access_token) } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::collections::HashMap; + use std::fs; + use std::path::PathBuf; + use tempfile::TempDir; + + const TEST_CERT_1: &str = "-----BEGIN CERTIFICATE-----\nMIIDBTCCAe2gAwIBAgIURA/8mcaBUM3VeC/959yHcE5qhg0wDQYJKoZIhvcNAQEL\nBQAwEjEQMA4GA1UEAwwHdGVzdC1jYTAeFw0yNTExMTkwMTI5NDJaFw0yNjExMTkw\nMTI5NDJaMBIxEDAOBgNVBAMMB3Rlc3QtY2EwggEiMA0GCSqGSIb3DQEBAQUAA4IB\nDwAwggEKAoIBAQCQlu3GsymtWBmmFeIYzObIWzV1/BcSYr34Q7etqNxz/FcPwVw0\nXKJ6K4+TH3kOcjnUyWazCdwKINDsniN3i9rzTnDhFxuU/kHfV2pYOAGd5zOqQZYG\nfathKAxZTGLcBFqG4EmfgwZURSugi5xPsT56UJAdOmoltkcyhy3xeRL1dK2xdi++\nCmZcI3fTD/e3ZwzubPXPUOSXRae2yt1C53p70uiA+6R9UlIIoFxpHh4cD2go8z6v\nqKwnkKWycGJD2LFXdYRYOHRP1px4OCsnLAteUjgUsGTu0K4uJEsJYyLdmhg0Dpjz\n148Cwh5UuRbkWUGvZ2BNCHZB8ttQO2g0RzP/AgMBAAGjUzBRMB0GA1UdDgQWBBTV\n08esey9TtVpv5K3saN8rUoc3KzAfBgNVHSMEGDAWgBTV08esey9TtVpv5K3saN8r\nUoc3KzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBcxHdHiiyY\nsoWsCd/PuQLFOb4iAD5Gkftb55lxyL/WBtDqbWMGqtWSKFbjlSyVCqcrRduCTOne\nWgT5h4vyHzpBwRTuL0E3E72Y/vVc2kZ8djaB/gBO/IsEs3jDgVIOZk5SmVPTzBxI\nPBc3Rp6TmMCmLzRrrVg5BpCIH/0YVcgH71abUGpiJJp+cVvet5Yh+1+HlFZriWit\nh1OZe3bUuYvLxLnYrRpn4kDvsCXZOPJmIhEtQvoxWTllj6Xp5cVZ5JZdVyx6g9+7\npw5sOGsOCrQ4RV6U22e7T/ClsN9TYfM+JzQeIbAD0LL7mASVxXGE/LJ7EVdyGbrL\nCnUUO0SOj4m4\n-----END CERTIFICATE-----\n"; + const TEST_CERT_2: &str = "-----BEGIN CERTIFICATE-----\nMIIDGTCCAgGgAwIBAgIUWxlcvHzwITWAHWHbKMFUTgeDmjwwDQYJKoZIhvcNAQEL\nBQAwHDEaMBgGA1UEAwwRdGVzdC1pbnRlcm1lZGlhdGUwHhcNMjUxMTE5MTU1MDIz\nWhcNMjYxMTE5MTU1MDIzWjAcMRowGAYDVQQDDBF0ZXN0LWludGVybWVkaWF0ZTCC\nASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANq7xbeYpC2GaXANqD1nLk0t\nj9j2sOk6e7DqTapxnIUijS7z4DF0Vo1xHM07wK1m+wsB/t9CubNYRvtn6hrIzx7K\njjlmvxo4/YluwO1EDMQWZAXkaY2O28ESKVx7QLfBPYAc4bf/5B4Nmt6KX5sQyyyH\n2qTfzVBUCAl3sI+Ydd3mx7NOye1yNNkCNqyK3Hj45F1JuH8NZxcb4OlKssZhMlD+\nEQx4G46AzKE9Ho8AqlQvg/tiWrMHRluw7zolMJ/AXzedAXedNIrX4fCOmZwcTkA1\na8eLPP8oM9VFrr67a7on6p4zPqugUEQ4fawp7A5KqSjUAVCt1FXmn2V8N8V6W/sC\nAwEAAaNTMFEwHQYDVR0OBBYEFBEwRwW0gm3IjhLw1U3eOAvR0r6SMB8GA1UdIwQY\nMBaAFBEwRwW0gm3IjhLw1U3eOAvR0r6SMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI\nhvcNAQELBQADggEBAB2fjAlpevK42Odv8XUEgV6VWlEP9HAmkRvugW9hjhzx1Iz9\nVh/l9VcxL7PcqdpyGH+BIRvQIMokcYF5TXzf/KV1T2y56U8AWaSd2/xSjYNWwkgE\nTLE5V+H/YDKzvTe58UrOaxa5N3URscQL9f+ZKworODmfMlkJ1mlREK130ZMlBexB\np9w5wo1M1fjx76Rqzq9MkpwBSbIO2zx/8+qy4BAH23MPGW+9OOnnq2DiIX3qUu1v\nhnjYOxYpCB28MZEJmqsjFJQQ9RF+Te4U2/oknVcf8lZIMJ2ZBOwt2zg8RqCtM52/\nIbATwYj77wg3CFLFKcDYs3tdUqpiniabKcf6zAs=\n-----END CERTIFICATE-----\n"; + struct MapEnv { + values: HashMap, + } + + impl EnvSource for MapEnv { + fn var(&self, key: &str) -> Option { + self.values.get(key).cloned() + } + } + + fn map_env(pairs: &[(&str, &str)]) -> MapEnv { + MapEnv { + values: pairs + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect(), + } + } + + #[test] + fn ca_path_prefers_codex_env() { + let env = map_env(&[ + (CODEX_CA_CERT_ENV, "/tmp/codex.pem"), + (SSL_CERT_FILE_ENV, "/tmp/fallback.pem"), + ]); + + assert_eq!( + login_ca_certificate_path(&env), + Some(PathBuf::from("/tmp/codex.pem")) + ); + } + + #[test] + fn ca_path_falls_back_to_ssl_cert_file() { + let env = map_env(&[(SSL_CERT_FILE_ENV, "/tmp/fallback.pem")]); + + assert_eq!( + login_ca_certificate_path(&env), + Some(PathBuf::from("/tmp/fallback.pem")) + ); + } + + #[test] + fn ca_path_ignores_empty_values() { + let env = map_env(&[ + (CODEX_CA_CERT_ENV, ""), + (SSL_CERT_FILE_ENV, "/tmp/fallback.pem"), + ]); + + assert_eq!( + login_ca_certificate_path(&env), + Some(PathBuf::from("/tmp/fallback.pem")) + ); + } + + fn build_client(env: MapEnv) -> io::Result { + build_login_http_client_with_env(&env) + } + + fn write_test_cert(temp_dir: &TempDir, file_name: &str, contents: &str) -> PathBuf { + let path = temp_dir.path().join(file_name); + fs::write(&path, contents).expect("write cert fixture"); + path + } + + #[test] + fn build_client_uses_codex_ca_cert_env() { + let temp_dir = TempDir::new().expect("tempdir"); + let cert_path = write_test_cert(&temp_dir, "ca.pem", TEST_CERT_1); + let env = map_env(&[(CODEX_CA_CERT_ENV, cert_path.to_str().unwrap())]); + + let client = build_client(env); + + assert!(client.is_ok(), "Failed to build client: {:?}", client.err()); + } + + #[test] + fn build_client_uses_ssl_cert_file_fallback() { + let temp_dir = TempDir::new().expect("tempdir"); + let cert_path = write_test_cert(&temp_dir, "ssl-cert.pem", TEST_CERT_1); + let env = map_env(&[(SSL_CERT_FILE_ENV, cert_path.to_str().unwrap())]); + + let client = build_client(env); + + assert!(client.is_ok(), "Failed to build client: {:?}", client.err()); + } + + #[test] + fn build_client_rejects_invalid_certificate_data() { + let temp_dir = TempDir::new().expect("tempdir"); + let cert_path = write_test_cert(&temp_dir, "invalid.pem", "not-a-certificate"); + let env = map_env(&[(CODEX_CA_CERT_ENV, cert_path.to_str().unwrap())]); + + let client = build_client(env); + + let err = client.expect_err("client should fail for invalid cert"); + assert!(err.to_string().contains("no certificates found")); + } + + #[test] + fn build_client_handles_multi_certificate_bundle() { + let temp_dir = TempDir::new().expect("tempdir"); + let bundle = format!("{TEST_CERT_1}\\n{TEST_CERT_2}"); + let cert_path = write_test_cert(&temp_dir, "bundle.pem", &bundle); + let env = map_env(&[(CODEX_CA_CERT_ENV, cert_path.to_str().unwrap())]); + + let client = build_client(env); + + assert!( + client.is_ok(), + "Failed to build client with bundle: {:?}", + client.err() + ); + } + + #[test] + fn build_client_rejects_empty_pem_file() { + let temp_dir = TempDir::new().expect("tempdir"); + let cert_path = write_test_cert(&temp_dir, "empty.pem", ""); + let env = map_env(&[(CODEX_CA_CERT_ENV, cert_path.to_str().unwrap())]); + + let client = build_client(env); + + let err = client.expect_err("client should fail for empty cert file"); + assert!(err.to_string().contains("no certificates found")); + } + + #[test] + fn build_client_rejects_malformed_pem() { + let temp_dir = TempDir::new().expect("tempdir"); + let cert_path = write_test_cert( + &temp_dir, + "malformed.pem", + "-----BEGIN CERTIFICATE-----\\nMIIBroken", + ); + let env = map_env(&[(CODEX_CA_CERT_ENV, cert_path.to_str().unwrap())]); + + let client = build_client(env); + + let err = client.expect_err("client should fail for malformed cert"); + assert!(err.to_string().contains("failed to parse PEM file")); + } +} diff --git a/codex-rs/login/tests/ca_env.rs b/codex-rs/login/tests/ca_env.rs new file mode 100644 index 00000000000..9d6fb9f1d1e --- /dev/null +++ b/codex-rs/login/tests/ca_env.rs @@ -0,0 +1,170 @@ +use assert_cmd::cargo::CommandCargoExt; +use std::fs; +use std::path::Path; +use std::process::Command; +use tempfile::TempDir; + +const CODEX_CA_CERT_ENV: &str = "CODEX_CA_CERTIFICATE"; +const SSL_CERT_FILE_ENV: &str = "SSL_CERT_FILE"; + +const TEST_CERT_1: &str = "-----BEGIN CERTIFICATE----- +MIIDBTCCAe2gAwIBAgIUZYhGvBUG7SucNzYh9VIeZ7b9zHowDQYJKoZIhvcNAQEL +BQAwEjEQMA4GA1UEAwwHdGVzdC1jYTAeFw0yNTEyMTEyMzEyNTFaFw0zNTEyMDky +MzEyNTFaMBIxEDAOBgNVBAMMB3Rlc3QtY2EwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQC+NJRZAdn15FFBN8eR1HTAe+LMVpO19kKtiCsQjyqHONfhfHcF +7zQfwmH6MqeNpC/5k5m8V1uSIhyHBskQm83Jv8/vHlffNxE/hl0Na/Yd1bc+2kxH +twIAsF32GKnSKnFva/iGczV81+/ETgG6RXfTfy/Xs6fXL8On8SRRmTcMw0bEfwko +ziid87VOHg2JfdRKN5QpS9lvQ8q4q2M3jMftolpUTpwlR0u8j9OXnZfn+ja33X0l +kjkoCbXE2fVbAzO/jhUHQX1H5RbTGGUnrrCWAj84Rq/E80KK1nrRF91K+vgZmilM +gOZosLMMI1PeqTakwg1yIRngpTyk0eJP+haxAgMBAAGjUzBRMB0GA1UdDgQWBBT6 +sqvfjMIl0DFZkeu8LU577YqMVDAfBgNVHSMEGDAWgBT6sqvfjMIl0DFZkeu8LU57 +7YqMVDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBQ1sYs2RvB +TZ+xSBglLwH/S7zXVJIDwQ23Rlj11dgnVvcilSJCX24Rr+pfIVLpYNDdZzc/DIJd +S1dt2JuLnvXnle29rU7cxuzYUkUkRtaeY2Sj210vsE3lqUFyIy8XCc/lteb+FiJ7 +zo/gPk7P+y4ihK9Mm6SBqkDVEYSFSn9bgoemK+0e93jGe2182PyuTwfTmZgENSBO +2f9dSuay4C7e5UO8bhVccQJg6f4d70zUNG0oPHrnVxJLjwCd++jx25Gh4U7+ek13 +CW57pxJrpPMDWb2YK64rT2juHMKF73YuplW92SInd+QLpI2ekTLc+bRw8JvqzXg+ +SprtRUBjlWzj +-----END CERTIFICATE----- +"; + +const TEST_CERT_2: &str = "-----BEGIN CERTIFICATE----- +MIIDGTCCAgGgAwIBAgIUWxlcvHzwITWAHWHbKMFUTgeDmjwwDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRdGVzdC1pbnRlcm1lZGlhdGUwHhcNMjUxMTE5MTU1MDIz +WhcNMjYxMTE5MTU1MDIzWjAcMRowGAYDVQQDDBF0ZXN0LWludGVybWVkaWF0ZTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANq7xbeYpC2GaXANqD1nLk0t +j9j2sOk6e7DqTapxnIUijS7z4DF0Vo1xHM07wK1m+wsB/t9CubNYRvtn6hrIzx7K +jjlmvxo4/YluwO1EDMQWZAXkaY2O28ESKVx7QLfBPYAc4bf/5B4Nmt6KX5sQyyyH +2qTfzVBUCAl3sI+Ydd3mx7NOye1yNNkCNqyK3Hj45F1JuH8NZxcb4OlKssZhMlD+ +EQx4G46AzKE9Ho8AqlQvg/tiWrMHRluw7zolMJ/AXzedAXedNIrX4fCOmZwcTkA1 +a8eLPP8oM9VFrr67a7on6p4zPqugUEQ4fawp7A5KqSjUAVCt1FXmn2V8N8V6W/sC +AwEAAaNTMFEwHQYDVR0OBBYEFBEwRwW0gm3IjhLw1U3eOAvR0r6SMB8GA1UdIwQY +MBaAFBEwRwW0gm3IjhLw1U3eOAvR0r6SMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI +hvcNAQELBQADggEBAB2fjAlpevK42Odv8XUEgV6VWlEP9HAmkRvugW9hjhzx1Iz9 +Vh/l9VcxL7PcqdpyGH+BIRvQIMokcYF5TXzf/KV1T2y56U8AWaSd2/xSjYNWwkgE +TLE5V+H/YDKzvTe58UrOaxa5N3URscQL9f+ZKworODmfMlkJ1mlREK130ZMlBexB +p9w5wo1M1fjx76Rqzq9MkpwBSbIO2zx/8+qy4BAH23MPGW+9OOnnq2DiIX3qUu1v +hnjYOxYpCB28MZEJmqsjFJQQ9RF+Te4U2/oknVcf8lZIMJ2ZBOwt2zg8RqCtM52/ +IbATwYj77wg3CFLFKcDYs3tdUqpiniabKcf6zAs= +-----END CERTIFICATE----- +"; + +fn write_cert_file(temp_dir: &TempDir, name: &str, contents: &str) -> std::path::PathBuf { + let path = temp_dir.path().join(name); + fs::write(&path, contents).unwrap_or_else(|error| { + panic!("write cert fixture failed for {}: {error}", path.display()) + }); + path +} + +fn run_probe(envs: &[(&str, &Path)]) -> std::process::Output { + let mut cmd = Command::cargo_bin("login_ca_probe") + .unwrap_or_else(|error| panic!("failed to locate login_ca_probe: {error}")); + for (key, value) in envs { + cmd.env(key, value); + } + cmd.output() + .unwrap_or_else(|error| panic!("failed to run login_ca_probe: {error}")) +} + +#[test] +fn uses_codex_ca_cert_env() { + let temp_dir = TempDir::new().expect("tempdir"); + let cert_path = write_cert_file(&temp_dir, "ca.pem", TEST_CERT_1); + + let output = run_probe(&[(CODEX_CA_CERT_ENV, cert_path.as_path())]); + + assert!(output.status.success()); +} + +#[test] +fn falls_back_to_ssl_cert_file() { + let temp_dir = TempDir::new().expect("tempdir"); + let cert_path = write_cert_file(&temp_dir, "ssl.pem", TEST_CERT_1); + + let output = run_probe(&[(SSL_CERT_FILE_ENV, cert_path.as_path())]); + + assert!(output.status.success()); +} + +#[test] +fn prefers_codex_ca_cert_over_ssl_cert_file() { + let temp_dir = TempDir::new().expect("tempdir"); + let cert_path = write_cert_file(&temp_dir, "ca.pem", TEST_CERT_1); + let bad_path = write_cert_file(&temp_dir, "bad.pem", ""); + + let output = run_probe(&[ + (CODEX_CA_CERT_ENV, cert_path.as_path()), + (SSL_CERT_FILE_ENV, bad_path.as_path()), + ]); + + assert!(output.status.success()); +} + +#[test] +fn handles_multi_certificate_bundle() { + let temp_dir = TempDir::new().expect("tempdir"); + let bundle = format!("{TEST_CERT_1}\n{TEST_CERT_2}"); + let cert_path = write_cert_file(&temp_dir, "bundle.pem", &bundle); + + let output = run_probe(&[(CODEX_CA_CERT_ENV, cert_path.as_path())]); + + assert!(output.status.success()); +} + +#[test] +fn rejects_empty_pem_file_with_hint() { + let temp_dir = TempDir::new().expect("tempdir"); + let cert_path = write_cert_file(&temp_dir, "empty.pem", ""); + + let output = run_probe(&[(CODEX_CA_CERT_ENV, cert_path.as_path())]); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("no certificates found in PEM file")); + assert!(stderr.contains("CODEX_CA_CERTIFICATE")); + assert!(stderr.contains("SSL_CERT_FILE")); +} + +#[test] +fn rejects_malformed_pem_with_hint() { + let temp_dir = TempDir::new().expect("tempdir"); + let cert_path = write_cert_file( + &temp_dir, + "malformed.pem", + "-----BEGIN CERTIFICATE-----\nMIIBroken", + ); + + let output = run_probe(&[(CODEX_CA_CERT_ENV, cert_path.as_path())]); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("failed to parse PEM file")); + assert!(stderr.contains("CODEX_CA_CERTIFICATE")); + assert!(stderr.contains("SSL_CERT_FILE")); +} + +#[test] +fn accepts_trusted_certificate_label() { + let temp_dir = TempDir::new().expect("tempdir"); + let trusted = TEST_CERT_1 + .replace("BEGIN CERTIFICATE", "BEGIN TRUSTED CERTIFICATE") + .replace("END CERTIFICATE", "END TRUSTED CERTIFICATE"); + let cert_path = write_cert_file(&temp_dir, "trusted.pem", &trusted); + + let output = run_probe(&[(CODEX_CA_CERT_ENV, cert_path.as_path())]); + + assert!(output.status.success()); +} + +#[test] +fn accepts_bundle_with_crl() { + let temp_dir = TempDir::new().expect("tempdir"); + let crl = "-----BEGIN X509 CRL-----\nMIIC\n-----END X509 CRL-----"; + let bundle = format!("{TEST_CERT_1}\n{crl}"); + let cert_path = write_cert_file(&temp_dir, "bundle_crl.pem", &bundle); + + let output = run_probe(&[(CODEX_CA_CERT_ENV, cert_path.as_path())]); + + assert!(output.status.success()); +} diff --git a/docs/config.md b/docs/config.md index 8452a831e25..137fdd96796 100644 --- a/docs/config.md +++ b/docs/config.md @@ -994,6 +994,50 @@ Valid values: - FreeBSD/OpenBSD: DBus‑based Secret Service - `auto` – Save credentials to the operating system keyring when available; otherwise, fall back to `auth.json` under `$CODEX_HOME`. +### Custom CA certificates for login + +When running behind a corporate proxy that performs SSL/TLS inspection with custom CA certificates, the `codex login` flow may fail during token exchange. To resolve this, you can configure Codex to trust additional certificate authorities using environment variables. + +Codex supports two environment variables for specifying custom CA certificates (checked in priority order): + +1. **`CODEX_CA_CERTIFICATE`** (primary) – Path to a PEM file containing one or more CA certificates +2. **`SSL_CERT_FILE`** (fallback) – Standard environment variable used by many tools for CA certificate bundles + +**Usage:** + +```bash +# Using CODEX_CA_CERTIFICATE (recommended for Codex-specific configuration) +export CODEX_CA_CERTIFICATE=/path/to/corporate-ca.pem +codex login + +# Or using SSL_CERT_FILE (useful if already set system-wide) +export SSL_CERT_FILE=/path/to/ca-bundle.pem +codex login +``` + +**Certificate format:** + +The PEM file can contain either a single certificate or a bundle of multiple certificates (root + intermediates). Both formats are supported: + +```pem +# Single certificate +-----BEGIN CERTIFICATE----- +MIIDBTCCAe2gAwIBAgI... +-----END CERTIFICATE----- + +# Or a bundle (multiple certificates) +-----BEGIN CERTIFICATE----- +MIIDBTCCAe2gAwIBAgI... (root CA) +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDGTCCA... (intermediate CA) +-----END CERTIFICATE----- +``` + +**Corporate proxy setup:** + +Corporate proxies typically distribute CA bundles containing root and intermediate certificates. Export your corporate CA certificate bundle to a PEM file and set one of the environment variables above. The certificates will be trusted for all OAuth authentication flows (browser login, device code login, and API key exchange). + ## Config reference | Key | Type / Values | Notes |