mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
login: add custom CA support for login flows (#14178)
## Stacked PRs This work is split across three stacked PRs: - #14178: add custom CA support for browser and device-code login flows, docs, and hermetic subprocess tests - #14239: broaden the shared custom CA path from login to other outbound `reqwest` clients across Codex - #14240: extend that shared custom CA handling to secure websocket TLS so websocket connections honor the same CA env vars Review order: #14178, then #14239, then #14240. Supersedes #6864. Thanks to @3axap4eHko for the original implementation and investigation here. Although this version rearranges the code and history significantly, the majority of the credit for this work belongs to them. ## Problem Login flows need to work in enterprise environments where outbound TLS is intercepted by an internal proxy or gateway. In those setups, system root certificates alone are often insufficient to validate the OAuth and device-code endpoints used during login. The change adds a login-specific custom CA loading path, but the important contracts around env precedence, PEM compatibility, test boundaries, and probe-only workarounds need to be explicit so reviewers can understand what behavior is intentional. For users and operators, the behavior is simple: if login needs to trust a custom root CA, set `CODEX_CA_CERTIFICATE` to a PEM file containing one or more certificates. If that variable is unset, login falls back to `SSL_CERT_FILE`. If neither is set, login uses system roots. Invalid or empty PEM files now fail with an error that points back to those environment variables and explains how to recover. ## What This Delivers Users can now make Codex login work behind enterprise TLS interception by pointing `CODEX_CA_CERTIFICATE` at a PEM bundle containing the relevant root certificates. If that variable is unset, login falls back to `SSL_CERT_FILE`, then to system roots. This PR applies that behavior to both browser-based and device-code login flows. It also makes login tolerant of the PEM shapes operators actually have in hand: multi-certificate bundles, OpenSSL `TRUSTED CERTIFICATE` labels, and bundles that include well-formed CRLs. ## Mental model `codex-login` is the place where the login flows construct ad hoc outbound HTTP clients. That makes it the right boundary for a narrow CA policy: look for `CODEX_CA_CERTIFICATE`, fall back to `SSL_CERT_FILE`, load every parseable certificate block in that bundle into a `reqwest::Client`, and fail early with a clear user-facing error if the bundle is unreadable or malformed. The implementation is intentionally pragmatic about PEM input shape. It accepts ordinary certificate bundles, multi-certificate bundles, OpenSSL `TRUSTED CERTIFICATE` labels, and bundles that also contain CRLs. It does not validate a certificate chain or prove a handshake; it only constructs the root store used by login. ## Non-goals This change does not introduce a general-purpose transport abstraction for the rest of the product. It does not validate whether the provided bundle forms a real chain, and it does not add handshake-level integration tests against a live TLS server. It also does not change login state management or OAuth semantics beyond ensuring the existing flows share the same CA-loading rules. ## Tradeoffs The main tradeoff is keeping this logic scoped to login-specific client construction rather than lifting it into a broader shared HTTP layer. That keeps the review surface smaller, but it also means future login-adjacent code must continue to use `build_login_http_client()` or it can silently bypass enterprise CA overrides. The `TRUSTED CERTIFICATE` handling is also intentionally a local compatibility shim. The rustls ecosystem does not currently accept that PEM label upstream, so the code normalizes it locally and trims the OpenSSL `X509_AUX` trailer bytes down to the certificate DER that `reqwest` can consume. ## Architecture `custom_ca.rs` is now the single place that owns login CA behavior. It selects the CA file from the environment, reads it, normalizes PEM label shape where needed, iterates mixed PEM sections with `rustls-pki-types`, ignores CRLs, trims OpenSSL trust metadata when necessary, and returns either a configured `reqwest::Client` or a typed error. The browser login server and the device-code flow both call `build_login_http_client()`, so they share the same trust-store policy. Environment-sensitive tests run through the `login_ca_probe` helper binary because those tests must control process-wide env vars and cannot reliably build a real reqwest client in-process on macOS seatbelt runs. ## Observability The custom CA path logs which environment variable selected the bundle, which file path was loaded, how many certificates were accepted, when `TRUSTED CERTIFICATE` labels were normalized, when CRLs were ignored, and where client construction failed. Returned errors remain user-facing and include the relevant path, env var, and remediation hint. This gives enough signal for three audiences: - users can see why login failed and which env/file caused it - sysadmins can confirm which override actually won - developers can tell whether the failure happened during file read, PEM parsing, certificate registration, or final reqwest client construction ## Tests Pure unit tests stay limited to env precedence and empty-value handling. Real client construction lives in subprocess tests so the suite remains hermetic with respect to process env and macOS sandbox behavior. The subprocess tests verify: - `CODEX_CA_CERTIFICATE` precedence over `SSL_CERT_FILE` - fallback to `SSL_CERT_FILE` - single-certificate and multi-certificate bundles - malformed and empty-bundle errors - OpenSSL `TRUSTED CERTIFICATE` handling - CRL tolerance for well-formed CRL sections The named PEM fixtures under `login/tests/fixtures/` are shared by the tests so their purpose stays reviewable. --------- Co-authored-by: Ivan Zakharchanka <3axap4eHko@gmail.com> Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
3
codex-rs/Cargo.lock
generated
3
codex-rs/Cargo.lock
generated
@@ -2134,14 +2134,17 @@ dependencies = [
|
||||
"chrono",
|
||||
"codex-app-server-protocol",
|
||||
"codex-core",
|
||||
"codex-utils-cargo-bin",
|
||||
"core_test_support",
|
||||
"pretty_assertions",
|
||||
"rand 0.9.2",
|
||||
"reqwest",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tiny_http",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
||||
@@ -240,6 +240,7 @@ rustls = { version = "0.23", default-features = false, features = [
|
||||
"ring",
|
||||
"std",
|
||||
] }
|
||||
rustls-pki-types = "1.14.0"
|
||||
schemars = "0.8.22"
|
||||
seccompiler = "0.5.0"
|
||||
semver = "1.0"
|
||||
|
||||
@@ -14,11 +14,12 @@ 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 }
|
||||
tempfile = { workspace = true }
|
||||
tiny_http = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
"io-std",
|
||||
"macros",
|
||||
@@ -33,6 +34,7 @@ webbrowser = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = { workspace = true }
|
||||
codex-utils-cargo-bin = { workspace = true }
|
||||
core_test_support = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
31
codex-rs/login/src/bin/login_ca_probe.rs
Normal file
31
codex-rs/login/src/bin/login_ca_probe.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! The probe intentionally disables reqwest proxy autodetection while building the client. That
|
||||
//! keeps the subprocess tests hermetic in macOS seatbelt runs, where
|
||||
//! `reqwest::Client::builder().build()` can panic inside the `system-configuration` crate while
|
||||
//! probing macOS proxy settings. Without that workaround, the subprocess exits before the custom
|
||||
//! CA code reports either success or a structured `BuildLoginHttpClientError`, so tests that are
|
||||
//! supposed to validate CA parsing instead fail on unrelated platform proxy discovery.
|
||||
|
||||
use std::process;
|
||||
|
||||
fn main() {
|
||||
match codex_login::probe_support::build_login_http_client() {
|
||||
Ok(_) => {
|
||||
println!("ok");
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
658
codex-rs/login/src/custom_ca.rs
Normal file
658
codex-rs/login/src/custom_ca.rs
Normal file
@@ -0,0 +1,658 @@
|
||||
//! Custom CA handling for login HTTP clients.
|
||||
//!
|
||||
//! Login flows are the only place this crate constructs ad hoc outbound HTTP clients, so this
|
||||
//! module centralizes the trust-store behavior that those clients must share. Enterprise networks
|
||||
//! often terminate TLS with an internal root CA, which means system roots alone cannot validate
|
||||
//! the OAuth and device-code endpoints that the login flows call.
|
||||
//!
|
||||
//! The module intentionally has a narrow responsibility:
|
||||
//!
|
||||
//! - read CA material from `CODEX_CA_CERTIFICATE`, falling back to `SSL_CERT_FILE`
|
||||
//! - normalize PEM variants that show up in real deployments, including OpenSSL-style
|
||||
//! `TRUSTED CERTIFICATE` labels and bundles that also contain CRLs
|
||||
//! - return user-facing errors that explain how to fix misconfigured CA files
|
||||
//!
|
||||
//! It does not validate certificate chains or perform a handshake in tests. Its contract is
|
||||
//! narrower: produce a `reqwest::Client` whose root store contains every parseable certificate
|
||||
//! block from the configured PEM bundle, or fail early with a precise error before the caller
|
||||
//! starts a login flow.
|
||||
//!
|
||||
//! The tests in this module therefore split on that boundary:
|
||||
//!
|
||||
//! - unit tests cover pure env-selection logic without constructing a real client
|
||||
//! - subprocess tests in `tests/ca_env.rs` cover real client construction, because that path is
|
||||
//! not hermetic in macOS sandboxed runs and must also scrub inherited CA environment variables
|
||||
//! - the spawned `login_ca_probe` binary reaches the probe-only builder through the hidden
|
||||
//! `probe_support` module so that workaround does not become part of the normal crate API
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use rustls_pki_types::CertificateDer;
|
||||
use rustls_pki_types::pem::PemObject;
|
||||
use rustls_pki_types::pem::SectionKind;
|
||||
use rustls_pki_types::pem::{self};
|
||||
use thiserror::Error;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
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.";
|
||||
type PemSection = (SectionKind, Vec<u8>);
|
||||
|
||||
/// Describes why the login HTTP client could not be constructed.
|
||||
///
|
||||
/// This boundary is more specific than `io::Error`: login can fail because the configured CA file
|
||||
/// could not be read, could not be parsed as certificates, contained certs that `reqwest` refused
|
||||
/// to register, or because the final client builder failed. The rest of the login crate still
|
||||
/// speaks `io::Error`, so callers that do not care about the distinction can rely on the
|
||||
/// `From<BuildLoginHttpClientError> for io::Error` conversion.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum BuildLoginHttpClientError {
|
||||
/// Reading the selected CA file from disk failed before any PEM parsing could happen.
|
||||
#[error(
|
||||
"Failed to read CA certificate file {} selected by {}: {source}. {hint}",
|
||||
path.display(),
|
||||
source_env,
|
||||
hint = CA_CERT_HINT
|
||||
)]
|
||||
ReadCaFile {
|
||||
source_env: &'static str,
|
||||
path: PathBuf,
|
||||
source: io::Error,
|
||||
},
|
||||
|
||||
/// The selected CA file was readable, but did not produce usable certificate material.
|
||||
#[error(
|
||||
"Failed to load CA certificates from {} selected by {}: {detail}. {hint}",
|
||||
path.display(),
|
||||
source_env,
|
||||
hint = CA_CERT_HINT
|
||||
)]
|
||||
InvalidCaFile {
|
||||
source_env: &'static str,
|
||||
path: PathBuf,
|
||||
detail: String,
|
||||
},
|
||||
|
||||
/// One parsed certificate block could not be registered with the reqwest client builder.
|
||||
#[error(
|
||||
"Failed to parse certificate #{certificate_index} from {} selected by {}: {source}. {hint}",
|
||||
path.display(),
|
||||
source_env,
|
||||
hint = CA_CERT_HINT
|
||||
)]
|
||||
RegisterCertificate {
|
||||
source_env: &'static str,
|
||||
path: PathBuf,
|
||||
certificate_index: usize,
|
||||
source: reqwest::Error,
|
||||
},
|
||||
|
||||
/// Reqwest rejected the final client configuration after a custom CA bundle was loaded.
|
||||
#[error(
|
||||
"Failed to build login HTTP client while using CA bundle from {} ({}): {source}",
|
||||
source_env,
|
||||
path.display()
|
||||
)]
|
||||
BuildClientWithCustomCa {
|
||||
source_env: &'static str,
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: reqwest::Error,
|
||||
},
|
||||
|
||||
/// Reqwest rejected the final client configuration while using only system roots.
|
||||
#[error("Failed to build login HTTP client while using system root certificates: {0}")]
|
||||
BuildClientWithSystemRoots(#[source] reqwest::Error),
|
||||
}
|
||||
|
||||
impl From<BuildLoginHttpClientError> for io::Error {
|
||||
fn from(error: BuildLoginHttpClientError) -> Self {
|
||||
match error {
|
||||
BuildLoginHttpClientError::ReadCaFile { ref source, .. } => {
|
||||
io::Error::new(source.kind(), error)
|
||||
}
|
||||
BuildLoginHttpClientError::InvalidCaFile { .. }
|
||||
| BuildLoginHttpClientError::RegisterCertificate { .. } => {
|
||||
io::Error::new(io::ErrorKind::InvalidData, error)
|
||||
}
|
||||
BuildLoginHttpClientError::BuildClientWithCustomCa { .. }
|
||||
| BuildLoginHttpClientError::BuildClientWithSystemRoots(_) => io::Error::other(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the HTTP client used by login and device-code flows.
|
||||
///
|
||||
/// Callers should use this instead of constructing a raw `reqwest::Client` so every login entry
|
||||
/// point honors the same CA override behavior. A caller that bypasses this helper can silently
|
||||
/// regress enterprise login setups that rely on `CODEX_CA_CERTIFICATE` or `SSL_CERT_FILE`.
|
||||
/// `CODEX_CA_CERTIFICATE` takes precedence over `SSL_CERT_FILE`, and empty values for either are
|
||||
/// treated as unset so callers do not accidentally turn `VAR=""` into a bogus path lookup.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a [`BuildLoginHttpClientError`] when the configured CA file is unreadable, malformed,
|
||||
/// or contains a certificate block that `reqwest` cannot register as a root. Calling raw
|
||||
/// `reqwest::Client::builder()` instead would skip those user-facing errors and can make login
|
||||
/// failures in enterprise environments much harder to diagnose.
|
||||
pub fn build_login_http_client() -> Result<reqwest::Client, BuildLoginHttpClientError> {
|
||||
build_login_http_client_with_env(&ProcessEnv, reqwest::Client::builder())
|
||||
}
|
||||
|
||||
/// Builds the login HTTP client used behind the spawned CA probe binary.
|
||||
///
|
||||
/// This stays crate-private because normal callers should continue to go through
|
||||
/// [`build_login_http_client`]. The hidden `probe_support` module exposes this behavior only to
|
||||
/// `login_ca_probe`, which disables proxy autodetection so the subprocess tests can reach the
|
||||
/// custom-CA code path in sandboxed macOS test runs without crashing first in reqwest's platform
|
||||
/// proxy setup. Using this path for normal login would make the tests and production behavior
|
||||
/// diverge on proxy handling, which is exactly what the hidden module arrangement is trying to
|
||||
/// avoid.
|
||||
pub(crate) fn build_login_http_client_for_subprocess_tests()
|
||||
-> Result<reqwest::Client, BuildLoginHttpClientError> {
|
||||
build_login_http_client_with_env(
|
||||
&ProcessEnv,
|
||||
// The probe disables proxy autodetection so the subprocess tests can reach the custom-CA
|
||||
// code path even in macOS seatbelt runs, where platform proxy discovery can panic first.
|
||||
reqwest::Client::builder().no_proxy(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds a login HTTP client using an injected environment source and reqwest builder.
|
||||
///
|
||||
/// This exists so unit tests can exercise precedence and PEM-handling behavior deterministically.
|
||||
/// Production code should call [`build_login_http_client`] instead of supplying its own
|
||||
/// environment adapter, otherwise the tests and the real process environment can drift apart.
|
||||
/// This function is also the place where module responsibilities come together: it selects the CA
|
||||
/// bundle, delegates file parsing to [`ConfiguredCaBundle::load_certificates`], preserves the
|
||||
/// caller's chosen `reqwest` builder configuration, and finally registers each parsed certificate
|
||||
/// with that builder.
|
||||
fn build_login_http_client_with_env(
|
||||
env_source: &dyn EnvSource,
|
||||
mut builder: reqwest::ClientBuilder,
|
||||
) -> Result<reqwest::Client, BuildLoginHttpClientError> {
|
||||
if let Some(bundle) = env_source.configured_ca_bundle() {
|
||||
let certificates = bundle.load_certificates()?;
|
||||
|
||||
for (idx, cert) in certificates.iter().enumerate() {
|
||||
let certificate = match reqwest::Certificate::from_der(cert.as_ref()) {
|
||||
Ok(certificate) => certificate,
|
||||
Err(source) => {
|
||||
warn!(
|
||||
source_env = bundle.source_env,
|
||||
ca_path = %bundle.path.display(),
|
||||
certificate_index = idx + 1,
|
||||
error = %source,
|
||||
"failed to register login CA certificate"
|
||||
);
|
||||
return Err(BuildLoginHttpClientError::RegisterCertificate {
|
||||
source_env: bundle.source_env,
|
||||
path: bundle.path.clone(),
|
||||
certificate_index: idx + 1,
|
||||
source,
|
||||
});
|
||||
}
|
||||
};
|
||||
builder = builder.add_root_certificate(certificate);
|
||||
}
|
||||
return match builder.build() {
|
||||
Ok(client) => Ok(client),
|
||||
Err(source) => {
|
||||
warn!(
|
||||
source_env = bundle.source_env,
|
||||
ca_path = %bundle.path.display(),
|
||||
error = %source,
|
||||
"failed to build client after loading custom CA bundle"
|
||||
);
|
||||
Err(BuildLoginHttpClientError::BuildClientWithCustomCa {
|
||||
source_env: bundle.source_env,
|
||||
path: bundle.path.clone(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
info!(
|
||||
codex_ca_certificate_configured = false,
|
||||
ssl_cert_file_configured = false,
|
||||
"using system root certificates because no CA override environment variable was selected"
|
||||
);
|
||||
|
||||
match builder.build() {
|
||||
Ok(client) => Ok(client),
|
||||
Err(source) => {
|
||||
warn!(
|
||||
error = %source,
|
||||
"failed to build client while using system root certificates"
|
||||
);
|
||||
Err(BuildLoginHttpClientError::BuildClientWithSystemRoots(
|
||||
source,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Abstracts environment access so tests can cover precedence rules without mutating process-wide
|
||||
/// variables.
|
||||
trait EnvSource {
|
||||
/// Returns the environment variable value for `key`, if this source considers it set.
|
||||
///
|
||||
/// Implementations should return `None` for absent values and may also collapse unreadable
|
||||
/// process-environment states into `None`, because the login CA logic treats both cases as
|
||||
/// "no override configured". Callers build precedence and empty-string handling on top of this
|
||||
/// method, so implementations should not trim or normalize the returned string.
|
||||
fn var(&self, key: &str) -> Option<String>;
|
||||
|
||||
/// Returns a non-empty environment variable value interpreted as a filesystem path.
|
||||
///
|
||||
/// Empty strings are treated as unset because login uses presence here as a boolean "custom CA
|
||||
/// override requested" signal. This keeps the precedence logic from treating `VAR=""` as an
|
||||
/// attempt to open the current working directory or some other platform-specific oddity once it
|
||||
/// is converted into a path.
|
||||
fn non_empty_path(&self, key: &str) -> Option<PathBuf> {
|
||||
self.var(key)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// Returns the configured CA bundle and which environment variable selected it.
|
||||
///
|
||||
/// `CODEX_CA_CERTIFICATE` wins over `SSL_CERT_FILE` because it is the login-specific override.
|
||||
/// Keeping the winning variable name with the path lets later logging explain not only which
|
||||
/// file was used but also why that file was chosen.
|
||||
fn configured_ca_bundle(&self) -> Option<ConfiguredCaBundle> {
|
||||
self.non_empty_path(CODEX_CA_CERT_ENV)
|
||||
.map(|path| ConfiguredCaBundle {
|
||||
source_env: CODEX_CA_CERT_ENV,
|
||||
path,
|
||||
})
|
||||
.or_else(|| {
|
||||
self.non_empty_path(SSL_CERT_FILE_ENV)
|
||||
.map(|path| ConfiguredCaBundle {
|
||||
source_env: SSL_CERT_FILE_ENV,
|
||||
path,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads login CA configuration from the real process environment.
|
||||
///
|
||||
/// This is the production `EnvSource` implementation used by
|
||||
/// [`build_login_http_client`]. Tests substitute in-memory env maps so they can
|
||||
/// exercise precedence and empty-value behavior without mutating process-global
|
||||
/// variables.
|
||||
struct ProcessEnv;
|
||||
|
||||
impl EnvSource for ProcessEnv {
|
||||
fn var(&self, key: &str) -> Option<String> {
|
||||
env::var(key).ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifies the CA bundle selected for login and the policy decision that selected it.
|
||||
///
|
||||
/// This is the concrete output of the environment-precedence logic. Callers use `source_env` for
|
||||
/// logging and diagnostics, while `path` is the bundle that will actually be loaded.
|
||||
struct ConfiguredCaBundle {
|
||||
/// The environment variable that won the precedence check for this bundle.
|
||||
source_env: &'static str,
|
||||
/// The filesystem path that should be read as PEM certificate input.
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl ConfiguredCaBundle {
|
||||
/// Loads certificates from this selected CA bundle.
|
||||
///
|
||||
/// The bundle already represents the output of environment-precedence selection, so this is
|
||||
/// the natural point where the file-loading phase begins. The method owns the high-level
|
||||
/// success/failure logs for that phase and keeps the source env and path together for lower-
|
||||
/// level parsing and error shaping.
|
||||
fn load_certificates(&self) -> Result<Vec<CertificateDer<'static>>, BuildLoginHttpClientError> {
|
||||
match self.parse_certificates() {
|
||||
Ok(certificates) => {
|
||||
info!(
|
||||
source_env = self.source_env,
|
||||
ca_path = %self.path.display(),
|
||||
certificate_count = certificates.len(),
|
||||
"loaded certificates from custom CA bundle"
|
||||
);
|
||||
Ok(certificates)
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(
|
||||
source_env = self.source_env,
|
||||
ca_path = %self.path.display(),
|
||||
error = %error,
|
||||
"failed to load custom CA bundle"
|
||||
);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads every certificate block from a PEM file intended for login CA overrides.
|
||||
///
|
||||
/// This accepts a few common real-world variants so login behaves like other CA-aware tooling:
|
||||
/// leading comments are preserved, `TRUSTED CERTIFICATE` labels are normalized to standard
|
||||
/// certificate labels, and embedded CRLs are ignored.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `InvalidData` when the file cannot be interpreted as one or more certificates, and
|
||||
/// preserves the filesystem error kind when the file itself cannot be read.
|
||||
fn parse_certificates(
|
||||
&self,
|
||||
) -> Result<Vec<CertificateDer<'static>>, BuildLoginHttpClientError> {
|
||||
let pem_data = self.read_pem_data()?;
|
||||
let normalized_pem = NormalizedPem::from_pem_data(self.source_env, &self.path, &pem_data);
|
||||
|
||||
let mut certificates = Vec::new();
|
||||
let mut logged_crl_presence = false;
|
||||
// Use the mixed-section parser from `rustls-pki-types` so CRLs can be identified and
|
||||
// skipped explicitly instead of being removed with ad hoc text rewriting.
|
||||
for section_result in normalized_pem.sections() {
|
||||
// Known limitation: if `rustls-pki-types` fails while parsing a malformed CRL section,
|
||||
// that error is reported here before we can classify the block as ignorable. A bundle
|
||||
// containing valid certificates plus a malformed `X509 CRL` therefore still fails to
|
||||
// load today, even though well-formed CRLs are ignored.
|
||||
let (section_kind, der) = match section_result {
|
||||
Ok(section) => section,
|
||||
Err(error) => return Err(self.pem_parse_error(&error)),
|
||||
};
|
||||
match section_kind {
|
||||
SectionKind::Certificate => {
|
||||
let cert_der = normalized_pem.certificate_der(&der).ok_or_else(|| {
|
||||
self.invalid_ca_file(
|
||||
"failed to extract certificate data from TRUSTED CERTIFICATE: invalid DER length",
|
||||
)
|
||||
})?;
|
||||
certificates.push(CertificateDer::from(cert_der.to_vec()));
|
||||
}
|
||||
SectionKind::Crl => {
|
||||
if !logged_crl_presence {
|
||||
info!(
|
||||
source_env = self.source_env,
|
||||
ca_path = %self.path.display(),
|
||||
"ignoring X509 CRL entries found in custom CA bundle"
|
||||
);
|
||||
logged_crl_presence = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if certificates.is_empty() {
|
||||
return Err(self.pem_parse_error(&pem::Error::NoItemsFound));
|
||||
}
|
||||
|
||||
Ok(certificates)
|
||||
}
|
||||
/// Reads the CA bundle bytes while preserving the original filesystem error kind.
|
||||
///
|
||||
/// The caller wants a user-facing error that includes the bundle path and remediation hint, but
|
||||
/// the higher-level login surfaces still benefit from distinguishing "not found" from other I/O
|
||||
/// failures. This helper keeps both pieces together.
|
||||
fn read_pem_data(&self) -> Result<Vec<u8>, BuildLoginHttpClientError> {
|
||||
fs::read(&self.path).map_err(|source| BuildLoginHttpClientError::ReadCaFile {
|
||||
source_env: self.source_env,
|
||||
path: self.path.clone(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
/// Rewrites PEM parsing failures into user-facing configuration errors.
|
||||
///
|
||||
/// The underlying parser knows whether the file was empty, malformed, or contained unsupported
|
||||
/// PEM content, but callers need a message that also points them back to the relevant
|
||||
/// environment variables and the expected remediation.
|
||||
fn pem_parse_error(&self, error: &pem::Error) -> BuildLoginHttpClientError {
|
||||
let detail = match error {
|
||||
pem::Error::NoItemsFound => "no certificates found in PEM file".to_string(),
|
||||
_ => format!("failed to parse PEM file: {error}"),
|
||||
};
|
||||
|
||||
self.invalid_ca_file(detail)
|
||||
}
|
||||
|
||||
/// Creates an invalid-CA error tied to this file path.
|
||||
///
|
||||
/// Most parse-time failures in this module eventually collapse to "the configured CA bundle is
|
||||
/// not usable", but the detailed reason still matters for operator debugging. Centralizing that
|
||||
/// formatting keeps the path and hint text consistent across the different parser branches.
|
||||
fn invalid_ca_file(&self, detail: impl std::fmt::Display) -> BuildLoginHttpClientError {
|
||||
BuildLoginHttpClientError::InvalidCaFile {
|
||||
source_env: self.source_env,
|
||||
path: self.path.clone(),
|
||||
detail: detail.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The PEM text shape after OpenSSL compatibility normalization.
|
||||
///
|
||||
/// `Standard` means the input already used ordinary PEM certificate labels. `TrustedCertificate`
|
||||
/// means the input used OpenSSL's `TRUSTED CERTIFICATE` labels, so callers must also be prepared
|
||||
/// to trim trailing `X509_AUX` bytes from decoded certificate sections.
|
||||
enum NormalizedPem {
|
||||
/// PEM contents that already used ordinary `CERTIFICATE` labels.
|
||||
Standard(String),
|
||||
/// PEM contents rewritten from OpenSSL `TRUSTED CERTIFICATE` labels to `CERTIFICATE`.
|
||||
TrustedCertificate(String),
|
||||
}
|
||||
|
||||
impl NormalizedPem {
|
||||
/// Normalizes PEM text from a CA bundle into the label shape this module expects.
|
||||
///
|
||||
/// Login only needs certificate DER bytes to seed `reqwest`'s root store, but operators may
|
||||
/// point it at CA files that came from OpenSSL tooling rather than from a minimal certificate
|
||||
/// bundle. OpenSSL's `TRUSTED CERTIFICATE` form is one such variant: it is still certificate
|
||||
/// material, but it uses a different PEM label and may carry auxiliary trust metadata that
|
||||
/// this crate does not consume. This constructor rewrites only the PEM labels so the mixed-
|
||||
/// section parser can keep treating the file as certificate input. The rustls ecosystem does
|
||||
/// not currently accept `TRUSTED CERTIFICATE` as a standard certificate label upstream, so
|
||||
/// this remains a local compatibility shim rather than behavior delegated to
|
||||
/// `rustls-pki-types`.
|
||||
///
|
||||
/// See also:
|
||||
/// - rustls/pemfile issue #52, closed as not planned, documenting that
|
||||
/// `BEGIN TRUSTED CERTIFICATE` blocks are ignored upstream:
|
||||
/// <https://github.com/rustls/pemfile/issues/52>
|
||||
/// - OpenSSL `x509 -trustout`, which emits `TRUSTED CERTIFICATE` PEM blocks:
|
||||
/// <https://docs.openssl.org/master/man1/openssl-x509/>
|
||||
/// - OpenSSL PEM readers, which document that plain `PEM_read_bio_X509()` discards auxiliary
|
||||
/// trust settings:
|
||||
/// <https://docs.openssl.org/master/man3/PEM_read_bio_PrivateKey/>
|
||||
/// - `openssl s_server`, a real OpenSSL-based server/test tool that operates in this
|
||||
/// ecosystem:
|
||||
/// <https://docs.openssl.org/master/man1/openssl-s_server/>
|
||||
fn from_pem_data(source_env: &'static str, path: &Path, pem_data: &[u8]) -> Self {
|
||||
let pem = String::from_utf8_lossy(pem_data);
|
||||
if pem.contains("TRUSTED CERTIFICATE") {
|
||||
info!(
|
||||
source_env,
|
||||
ca_path = %path.display(),
|
||||
"normalizing OpenSSL TRUSTED CERTIFICATE labels in custom CA bundle"
|
||||
);
|
||||
Self::TrustedCertificate(
|
||||
pem.replace("BEGIN TRUSTED CERTIFICATE", "BEGIN CERTIFICATE")
|
||||
.replace("END TRUSTED CERTIFICATE", "END CERTIFICATE"),
|
||||
)
|
||||
} else {
|
||||
Self::Standard(pem.into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the normalized PEM contents regardless of the label shape that produced them.
|
||||
fn contents(&self) -> &str {
|
||||
match self {
|
||||
Self::Standard(contents) | Self::TrustedCertificate(contents) => contents,
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterates over every recognized PEM section in this normalized PEM text.
|
||||
///
|
||||
/// `rustls-pki-types` exposes mixed-section parsing through a `PemObject` implementation on the
|
||||
/// `(SectionKind, Vec<u8>)` tuple. Keeping that type-directed API here lets callers iterate in
|
||||
/// terms of normalized sections rather than trait plumbing.
|
||||
fn sections(&self) -> impl Iterator<Item = Result<PemSection, pem::Error>> + '_ {
|
||||
PemSection::pem_slice_iter(self.contents().as_bytes())
|
||||
}
|
||||
|
||||
/// Returns the certificate DER bytes for one parsed PEM certificate section.
|
||||
///
|
||||
/// Standard PEM certificates already decode to the exact DER bytes `reqwest` wants. OpenSSL
|
||||
/// `TRUSTED CERTIFICATE` sections may append `X509_AUX` bytes after the certificate, so those
|
||||
/// sections need to be trimmed down to their first DER object before registration.
|
||||
fn certificate_der<'a>(&self, der: &'a [u8]) -> Option<&'a [u8]> {
|
||||
match self {
|
||||
Self::Standard(_) => Some(der),
|
||||
Self::TrustedCertificate(_) => first_der_item(der),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the first DER-encoded ASN.1 object in `der`, ignoring any trailing OpenSSL metadata.
|
||||
///
|
||||
/// A PEM `CERTIFICATE` block usually decodes to exactly one DER blob: the certificate itself.
|
||||
/// OpenSSL's `TRUSTED CERTIFICATE` variant is different. It starts with that same certificate
|
||||
/// blob, but may append extra `X509_AUX` bytes after it to describe OpenSSL-specific trust
|
||||
/// settings. `reqwest::Certificate::from_der` only understands the certificate object, not those
|
||||
/// trailing OpenSSL extensions.
|
||||
///
|
||||
/// This helper therefore asks a narrower question than "is this a valid certificate?": where does
|
||||
/// the first top-level DER object end? If that boundary can be found, the caller keeps only that
|
||||
/// prefix and discards the trailing trust metadata. If it cannot be found, the input is treated as
|
||||
/// malformed CA data.
|
||||
fn first_der_item(der: &[u8]) -> Option<&[u8]> {
|
||||
der_item_length(der).map(|length| &der[..length])
|
||||
}
|
||||
|
||||
/// Returns the byte length of the first DER item in `der`.
|
||||
///
|
||||
/// DER is a binary encoding for ASN.1 objects. Each object begins with:
|
||||
///
|
||||
/// - a tag byte describing what kind of object follows
|
||||
/// - one or more length bytes describing how many content bytes belong to that object
|
||||
/// - the content bytes themselves
|
||||
///
|
||||
/// For this module, the important fact is that a certificate is stored as one complete top-level
|
||||
/// DER object. Once we know that object's declared length, we know exactly where the certificate
|
||||
/// ends and where any trailing OpenSSL `X509_AUX` data begins.
|
||||
///
|
||||
/// This helper intentionally parses only that outer length field. It does not validate the inner
|
||||
/// certificate structure, the meaning of the tag, or every nested ASN.1 value. That narrower scope
|
||||
/// is deliberate: the caller only needs a safe slice boundary for the leading certificate object
|
||||
/// before handing those bytes to `reqwest`, which performs the real certificate parsing.
|
||||
///
|
||||
/// The implementation supports the DER length forms needed here:
|
||||
///
|
||||
/// - short form, where the length is stored directly in the second byte
|
||||
/// - long form, where the second byte says how many following bytes make up the length value
|
||||
///
|
||||
/// Indefinite lengths are rejected because DER does not permit them, and any declared length that
|
||||
/// would run past the end of the input is treated as malformed.
|
||||
fn der_item_length(der: &[u8]) -> Option<usize> {
|
||||
let &length_octet = der.get(1)?;
|
||||
if length_octet & 0x80 == 0 {
|
||||
return Some(2 + usize::from(length_octet)).filter(|length| *length <= der.len());
|
||||
}
|
||||
|
||||
let length_octets = usize::from(length_octet & 0x7f);
|
||||
if length_octets == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let length_start = 2usize;
|
||||
let length_end = length_start.checked_add(length_octets)?;
|
||||
let length_bytes = der.get(length_start..length_end)?;
|
||||
let mut content_length = 0usize;
|
||||
for &byte in length_bytes {
|
||||
content_length = content_length
|
||||
.checked_mul(256)?
|
||||
.checked_add(usize::from(byte))?;
|
||||
}
|
||||
|
||||
length_end
|
||||
.checked_add(content_length)
|
||||
.filter(|length| *length <= der.len())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::CODEX_CA_CERT_ENV;
|
||||
use super::EnvSource;
|
||||
use super::SSL_CERT_FILE_ENV;
|
||||
|
||||
// Keep this module limited to pure precedence logic. Building a real reqwest client here is
|
||||
// not hermetic on macOS sandboxed test runs because client construction can consult platform
|
||||
// networking configuration and panic before the test asserts anything. The real client-building
|
||||
// cases live in `tests/ca_env.rs`, which exercises them in a subprocess with explicit env.
|
||||
struct MapEnv {
|
||||
values: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl EnvSource for MapEnv {
|
||||
fn var(&self, key: &str) -> Option<String> {
|
||||
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!(
|
||||
env.configured_ca_bundle().map(|bundle| bundle.path),
|
||||
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!(
|
||||
env.configured_ca_bundle().map(|bundle| bundle.path),
|
||||
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!(
|
||||
env.configured_ca_bundle().map(|bundle| bundle.path),
|
||||
Some(PathBuf::from("/tmp/fallback.pem"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use serde::de::{self};
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::build_login_http_client;
|
||||
use crate::pkce::PkceCodes;
|
||||
use crate::server::ServerOptions;
|
||||
use std::io;
|
||||
@@ -47,9 +48,7 @@ where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
s.trim()
|
||||
.parse::<u64>()
|
||||
.map_err(|e| de::Error::custom(format!("invalid u64 string: {e}")))
|
||||
s.trim().parse::<u64>().map_err(de::Error::custom)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -158,7 +157,7 @@ fn print_device_code_prompt(verification_url: &str, code: &str) {
|
||||
}
|
||||
|
||||
pub async fn request_device_code(opts: &ServerOptions) -> std::io::Result<DeviceCode> {
|
||||
let client = reqwest::Client::new();
|
||||
let client = build_login_http_client()?;
|
||||
let base_url = opts.issuer.trim_end_matches('/');
|
||||
let api_base_url = format!("{base_url}/api/accounts");
|
||||
let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?;
|
||||
@@ -175,7 +174,7 @@ pub async fn complete_device_code_login(
|
||||
opts: ServerOptions,
|
||||
device_code: DeviceCode,
|
||||
) -> 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!("{base_url}/api/accounts");
|
||||
|
||||
@@ -222,7 +221,6 @@ pub async fn complete_device_code_login(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Full device code login flow.
|
||||
pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
|
||||
let device_code = request_device_code(&opts).await?;
|
||||
print_device_code_prompt(&device_code.verification_url, &device_code.user_code);
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
mod custom_ca;
|
||||
mod device_code_auth;
|
||||
mod pkce;
|
||||
// Hidden because this exists only to let the spawned `login_ca_probe` binary call the
|
||||
// probe-specific client builder without exposing that workaround as part of the normal API.
|
||||
// `login_ca_probe` is a separate binary target, not a `#[cfg(test)]` module inside this crate, so
|
||||
// it cannot call crate-private helpers and would not see test-only modules.
|
||||
#[doc(hidden)]
|
||||
pub mod probe_support;
|
||||
mod server;
|
||||
|
||||
pub use custom_ca::BuildLoginHttpClientError;
|
||||
pub use custom_ca::build_login_http_client;
|
||||
pub use device_code_auth::DeviceCode;
|
||||
pub use device_code_auth::complete_device_code_login;
|
||||
pub use device_code_auth::request_device_code;
|
||||
|
||||
22
codex-rs/login/src/probe_support.rs
Normal file
22
codex-rs/login/src/probe_support.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
//! Test-only support for spawned login probe binaries.
|
||||
//!
|
||||
//! This module exists because `login_ca_probe` is compiled as a separate binary target, so it
|
||||
//! cannot call crate-private helpers directly. Keeping the probe entry point under a hidden module
|
||||
//! avoids surfacing it as part of the normal `codex-login` public API while still letting the
|
||||
//! subprocess tests share the real custom-CA client-construction code. It is intentionally not a
|
||||
//! general-purpose login API: the functions here exist only so the subprocess tests can exercise
|
||||
//! CA loading in a separate process without duplicating logic in the probe binary.
|
||||
|
||||
use crate::BuildLoginHttpClientError;
|
||||
|
||||
/// Builds the login HTTP client for the subprocess CA probe tests.
|
||||
///
|
||||
/// The probe disables reqwest proxy autodetection so it can exercise custom-CA success and
|
||||
/// failure in macOS seatbelt runs without tripping the known `system-configuration` panic during
|
||||
/// platform proxy discovery. This is intentionally not the main public login entry point: normal
|
||||
/// login callers should continue to use [`crate::build_login_http_client`]. A non-test caller that
|
||||
/// reached for this helper would mask real proxy behavior and risk debugging a code path that does
|
||||
/// not match production login.
|
||||
pub fn build_login_http_client() -> Result<reqwest::Client, BuildLoginHttpClientError> {
|
||||
crate::custom_ca::build_login_http_client_for_subprocess_tests()
|
||||
}
|
||||
@@ -23,6 +23,7 @@ use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::custom_ca::build_login_http_client;
|
||||
use crate::pkce::PkceCodes;
|
||||
use crate::pkce::generate_pkce;
|
||||
use base64::Engine;
|
||||
@@ -159,10 +160,13 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
|
||||
let server = server.clone();
|
||||
thread::spawn(move || -> io::Result<()> {
|
||||
while let Ok(request) = server.recv() {
|
||||
tx.blocking_send(request).map_err(|e| {
|
||||
eprintln!("Failed to send request to channel: {e}");
|
||||
io::Error::other("Failed to send request to channel")
|
||||
})?;
|
||||
match tx.blocking_send(request) {
|
||||
Ok(()) => {}
|
||||
Err(error) => {
|
||||
eprintln!("Failed to send request to channel: {error}");
|
||||
return Err(io::Error::other("Failed to send request to channel"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
@@ -668,7 +672,6 @@ fn sanitize_url_for_logging(url: &str) -> String {
|
||||
Err(_) => "<invalid-url>".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Exchanges an authorization code for tokens.
|
||||
///
|
||||
/// The returned error remains suitable for user-facing CLI/browser surfaces, so backend-provided
|
||||
@@ -689,7 +692,7 @@ pub(crate) async fn exchange_code_for_tokens(
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let client = build_login_http_client()?;
|
||||
info!(
|
||||
issuer = %sanitize_url_for_logging(issuer),
|
||||
redirect_uri = %redirect_uri,
|
||||
@@ -706,18 +709,21 @@ pub(crate) async fn exchange_code_for_tokens(
|
||||
urlencoding::encode(&pkce.code_verifier)
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
let err = redact_sensitive_error_url(err);
|
||||
.await;
|
||||
let resp = match resp {
|
||||
Ok(resp) => resp,
|
||||
Err(error) => {
|
||||
let error = redact_sensitive_error_url(error);
|
||||
error!(
|
||||
is_timeout = err.is_timeout(),
|
||||
is_connect = err.is_connect(),
|
||||
is_request = err.is_request(),
|
||||
error = %err,
|
||||
is_timeout = error.is_timeout(),
|
||||
is_connect = error.is_connect(),
|
||||
is_request = error.is_request(),
|
||||
error = %error,
|
||||
"oauth token exchange transport failure"
|
||||
);
|
||||
io::Error::other(err)
|
||||
})?;
|
||||
return Err(io::Error::other(error));
|
||||
}
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
@@ -1055,7 +1061,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")
|
||||
@@ -1079,7 +1085,6 @@ 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 pretty_assertions::assert_eq;
|
||||
|
||||
146
codex-rs/login/tests/ca_env.rs
Normal file
146
codex-rs/login/tests/ca_env.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
//! Subprocess coverage for custom CA behavior that must build a real reqwest client.
|
||||
//!
|
||||
//! These tests intentionally run through `login_ca_probe` instead of calling the helper in-process:
|
||||
//! reqwest client construction is not hermetic on macOS sandboxed runs, and these cases also need
|
||||
//! exact control over inherited CA environment variables. The probe disables reqwest proxy
|
||||
//! autodetection because `reqwest::Client::builder().build()` can panic inside
|
||||
//! `system-configuration` while probing macOS proxy settings under seatbelt. The probe-level
|
||||
//! workaround keeps these tests focused on custom-CA success and failure instead of failing first
|
||||
//! on unrelated platform proxy discovery. These tests still stop at client construction: they
|
||||
//! verify CA file selection, PEM parsing, and user-facing errors, not a full TLS handshake.
|
||||
|
||||
use codex_utils_cargo_bin::cargo_bin;
|
||||
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 = include_str!("fixtures/test-ca.pem");
|
||||
const TEST_CERT_2: &str = include_str!("fixtures/test-intermediate.pem");
|
||||
const TRUSTED_TEST_CERT: &str = include_str!("fixtures/test-ca-trusted.pem");
|
||||
|
||||
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::new(
|
||||
cargo_bin("login_ca_probe")
|
||||
.unwrap_or_else(|error| panic!("failed to locate login_ca_probe: {error}")),
|
||||
);
|
||||
// `Command` inherits the parent environment by default, so scrub CA-related variables first or
|
||||
// these tests can accidentally pass/fail based on the developer shell or CI runner.
|
||||
cmd.env_remove(CODEX_CA_CERT_ENV);
|
||||
cmd.env_remove(SSL_CERT_FILE_ENV);
|
||||
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_openssl_trusted_certificate() {
|
||||
let temp_dir = TempDir::new().expect("tempdir");
|
||||
let cert_path = write_cert_file(&temp_dir, "trusted.pem", TRUSTED_TEST_CERT);
|
||||
|
||||
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());
|
||||
}
|
||||
23
codex-rs/login/tests/fixtures/test-ca-trusted.pem
vendored
Normal file
23
codex-rs/login/tests/fixtures/test-ca-trusted.pem
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# Test-only OpenSSL trusted-certificate fixture generated from test-ca.pem with
|
||||
# `openssl x509 -addtrust serverAuth -trustout`.
|
||||
# The extra trailing bytes model the OpenSSL X509_AUX data that follows the
|
||||
# certificate DER in real TRUSTED CERTIFICATE bundles.
|
||||
-----BEGIN TRUSTED 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+
|
||||
SprtRUBjlWzjMAwwCgYIKwYBBQUHAwE=
|
||||
-----END TRUSTED CERTIFICATE-----
|
||||
21
codex-rs/login/tests/fixtures/test-ca.pem
vendored
Normal file
21
codex-rs/login/tests/fixtures/test-ca.pem
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Test-only self-signed CA fixture used for single-certificate loading.
|
||||
# These tests only verify PEM parsing and root-certificate registration, not a TLS handshake.
|
||||
-----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-----
|
||||
21
codex-rs/login/tests/fixtures/test-intermediate.pem
vendored
Normal file
21
codex-rs/login/tests/fixtures/test-intermediate.pem
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Second valid test-only certificate used for multi-certificate bundle coverage.
|
||||
# It is intentionally distinct from test-ca.pem; chain validation is not part of these tests.
|
||||
-----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-----
|
||||
@@ -36,6 +36,24 @@ Codex stores the SQLite-backed state DB under `sqlite_home` (config key) or the
|
||||
`CODEX_SQLITE_HOME` environment variable. When unset, WorkspaceWrite sandbox
|
||||
sessions default to a temp directory; other modes default to `CODEX_HOME`.
|
||||
|
||||
## Login Custom CA Certificates
|
||||
|
||||
Browser login and device-code login can trust a custom root CA bundle when
|
||||
enterprise proxies or gateways intercept TLS.
|
||||
|
||||
Set `CODEX_CA_CERTIFICATE` to the path of a PEM file containing one or more
|
||||
certificate blocks to use a login-specific CA bundle. If `CODEX_CA_CERTIFICATE`
|
||||
is unset, login falls back to `SSL_CERT_FILE`. If neither variable is set,
|
||||
login uses the system root certificates.
|
||||
|
||||
`CODEX_CA_CERTIFICATE` takes precedence over `SSL_CERT_FILE`. Empty values are
|
||||
treated as unset.
|
||||
|
||||
The PEM file may contain multiple certificates. Codex also tolerates OpenSSL
|
||||
`TRUSTED CERTIFICATE` labels and ignores well-formed `X509 CRL` sections in the
|
||||
same bundle. If the file is empty, unreadable, or malformed, login fails with a
|
||||
user-facing error that points back to these environment variables.
|
||||
|
||||
## Notices
|
||||
|
||||
Codex stores "do not show again" flags for some UI prompts under the `[notice]` table.
|
||||
|
||||
Reference in New Issue
Block a user