Add configuration-backed external authentication (#38054)

## What changed

- Add a host-owned external auth source that runtime account APIs cannot replace,
  clear, or log out, and keep its credentials process-local instead of writing
  them to auth storage.
- Let configured providers classify refresh failures. Preserve cached credentials
  after permanent reload failures while retaining transient behavior for runtime
  providers.
- Compare external ChatGPT token data when detecting auth changes so a refreshed
  access token is applied to a retried request.

## Testing

- Cover configured-auth immutability, process-local storage, permanent reload
  failures, runtime error classification, and refreshed headers after a `401`.

GitOrigin-RevId: c6d70a39325c8ba6ca65ae68e12b55609f00a876
This commit is contained in:
cooper-oai
2026-08-11 19:04:07 +00:00
committed by copyberry
parent 6dc3ac8721
commit b28aa476f4
3 changed files with 415 additions and 31 deletions
+122
View File
@@ -1,5 +1,8 @@
use codex_login::AuthHeaders;
use codex_login::CodexAuth;
use codex_login::ExternalAuth;
use codex_login::ExternalAuthFuture;
use codex_login::ExternalAuthRefreshContext;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
@@ -10,6 +13,55 @@ use core_test_support::test_codex::test_codex;
use http::HeaderMap;
use http::HeaderValue;
use http::header::AUTHORIZATION;
use pretty_assertions::assert_eq;
use std::sync::Arc;
use std::sync::Mutex;
use wiremock::Mock;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
const CHATGPT_ACCOUNT_ID: &str = "workspace-one";
const INITIAL_ACCESS_TOKEN: &str = "header.e30.initial";
const REFRESHED_ACCESS_TOKEN: &str = "header.e30.refreshed";
struct ScriptedExternalAuth {
current: Mutex<CodexAuth>,
refreshed: CodexAuth,
}
impl ExternalAuth for ScriptedExternalAuth {
fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> {
let auth = self
.current
.lock()
.map_err(|_| std::io::Error::other("external auth lock is poisoned"))
.map(|current| current.clone());
Box::pin(async move { auth })
}
fn refresh(&self, context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> {
let auth = if context.previous_account_id.as_deref() != Some(CHATGPT_ACCOUNT_ID) {
Err(std::io::Error::other(
"external auth refresh changed the ChatGPT workspace",
))
} else {
self.current
.lock()
.map_err(|_| std::io::Error::other("external auth lock is poisoned"))
.map(|mut current| {
*current = self.refreshed.clone();
self.refreshed.clone()
})
};
Box::pin(async move { auth })
}
}
fn external_chatgpt_auth(access_token: &str) -> std::io::Result<CodexAuth> {
CodexAuth::from_external_chatgpt_tokens(access_token, CHATGPT_ACCOUNT_ID, Some("enterprise"))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn header_auth_is_attached_to_responses_requests() -> anyhow::Result<()> {
@@ -40,3 +92,73 @@ async fn header_auth_is_attached_to_responses_requests() -> anyhow::Result<()> {
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn configured_external_auth_401_retry_uses_refreshed_chatgpt_headers() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.and(header(
"authorization",
format!("Bearer {INITIAL_ACCESS_TOKEN}"),
))
.and(header("chatgpt-account-id", CHATGPT_ACCOUNT_ID))
.respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.and(header(
"authorization",
format!("Bearer {REFRESHED_ACCESS_TOKEN}"),
))
.and(header("chatgpt-account-id", CHATGPT_ACCOUNT_ID))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(sse(vec![
ev_response_created("resp-1"),
ev_completed("resp-1"),
])),
)
.expect(1)
.mount(&server)
.await;
let mut builder = test_codex().with_auth(CodexAuth::from_api_key("seed"));
let test = builder.build_with_auto_env(&server).await?;
let external_auth = Arc::new(ScriptedExternalAuth {
current: Mutex::new(external_chatgpt_auth(INITIAL_ACCESS_TOKEN)?),
refreshed: external_chatgpt_auth(REFRESHED_ACCESS_TOKEN)?,
});
test.thread_manager
.auth_manager()
.set_configured_external_auth(external_auth.clone())
.await?;
test.submit_turn("hello").await?;
server.verify().await;
let requests = server
.received_requests()
.await
.expect("mock server should capture requests");
let authorization_headers = requests
.iter()
.filter(|request| request.url.path() == "/v1/responses")
.filter_map(|request| request.headers.get("authorization"))
.filter_map(|value| value.to_str().ok())
.map(str::to_string)
.collect::<Vec<_>>();
assert_eq!(
authorization_headers,
vec![
format!("Bearer {INITIAL_ACCESS_TOKEN}"),
format!("Bearer {REFRESHED_ACCESS_TOKEN}"),
]
);
Ok(())
}
+124
View File
@@ -1292,6 +1292,81 @@ impl ExternalAuth for StaticExternalAuth {
}
}
struct FailingExternalAuth {
auth: CodexAuth,
resolve_count: AtomicUsize,
}
impl ExternalAuth for FailingExternalAuth {
fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> {
let resolve_count = self.resolve_count.fetch_add(1, Ordering::SeqCst);
Box::pin(async move {
if resolve_count == 0 {
Ok(self.auth.clone())
} else {
Err(std::io::Error::other("external auth failed"))
}
})
}
fn refresh(&self, _context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> {
Box::pin(async { Err(std::io::Error::other("external auth failed")) })
}
fn classify_error(&self, error: std::io::Error) -> RefreshTokenError {
RefreshTokenError::Permanent(RefreshTokenFailedError::new(
RefreshTokenFailedReason::Other,
error.to_string(),
))
}
}
#[tokio::test]
async fn configured_auth_keeps_cached_credentials_after_permanent_reload_failure() {
let manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("seed"));
let auth = CodexAuth::from_api_key("configured-token");
let external_auth = Arc::new(FailingExternalAuth {
auth: auth.clone(),
resolve_count: AtomicUsize::new(0),
});
manager
.set_configured_external_auth(external_auth.clone())
.await
.expect("configured auth should install");
assert_eq!(external_auth.resolve_count.load(Ordering::SeqCst), 1);
assert_eq!(manager.auth().await, Some(auth.clone()));
assert_eq!(external_auth.resolve_count.load(Ordering::SeqCst), 2);
assert_eq!(
manager
.refresh_failure_for_auth(&auth)
.expect("permanent failure should be recorded")
.to_string(),
"external auth failed"
);
assert_eq!(manager.auth().await, Some(auth));
assert_eq!(external_auth.resolve_count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn runtime_external_auth_errors_remain_transient() {
let manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("seed"));
manager
.set_external_auth(Arc::new(FailingExternalAuth {
auth: CodexAuth::from_api_key("runtime-token"),
resolve_count: AtomicUsize::new(0),
}))
.await
.expect("runtime auth should install");
assert!(matches!(
manager.refresh_token_from_authority().await,
Err(RefreshTokenError::Transient(_))
));
}
#[tokio::test]
async fn external_auth_provider_can_install_headers() {
let mut headers = http::HeaderMap::new();
@@ -1331,6 +1406,55 @@ async fn external_auth_provider_can_install_headers() {
);
}
#[tokio::test]
async fn configured_external_auth_is_immutable_and_process_local() {
let codex_home = tempdir().expect("tempdir");
let manager = AuthManager::from_auth_for_testing_with_home(
CodexAuth::from_api_key("seed"),
codex_home.path().to_path_buf(),
);
let access_token = fake_jwt_for_auth_file_params(&AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: Some("enterprise".to_string()),
chatgpt_account_id: Some("workspace-one".to_string()),
})
.expect("fake access token");
let auth =
CodexAuth::from_external_chatgpt_tokens(&access_token, "workspace-one", Some("enterprise"))
.expect("external ChatGPT auth");
manager
.set_configured_external_auth(Arc::new(StaticExternalAuth(auth.clone())))
.await
.expect("configured auth should install");
manager.clear_external_auth();
assert!(manager.has_configured_external_auth());
assert_eq!(manager.auth().await, Some(auth.clone()));
assert!(matches!(
manager
.set_external_auth(Arc::new(StaticExternalAuth(auth.clone())))
.await,
Err(RefreshTokenError::Permanent(_))
));
let logout_error = manager
.logout()
.await
.expect_err("host-configured auth must not be logged out");
assert_eq!(logout_error.kind(), std::io::ErrorKind::PermissionDenied);
assert!(manager.has_configured_external_auth());
assert_eq!(manager.auth_cached(), Some(auth));
assert!(!get_auth_file(codex_home.path()).exists());
let ephemeral_storage = create_auth_storage(
codex_home.path().to_path_buf(),
AuthCredentialsStoreMode::Ephemeral,
AuthKeyringBackendKind::default(),
);
assert_eq!(ephemeral_storage.load().expect("load ephemeral auth"), None);
}
struct ProviderAuthScript {
tempdir: TempDir,
command: String,
+169 -31
View File
@@ -224,10 +224,47 @@ pub trait ExternalAuth: Send + Sync {
/// Refreshes auth and makes the returned value current for future `resolve()` calls.
fn refresh(&self, context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth>;
/// Maps a provider error into the retry policy used by configured-auth reload and recovery.
fn classify_error(&self, error: std::io::Error) -> RefreshTokenError {
RefreshTokenError::Transient(error)
}
}
pub type ExternalAuthFuture<'a, T> = Pin<Box<dyn Future<Output = std::io::Result<T>> + Send + 'a>>;
#[derive(Clone)]
enum ExternalAuthSource {
Configured(Arc<dyn ExternalAuth>),
Runtime(Arc<dyn ExternalAuth>),
}
impl ExternalAuthSource {
fn provider(&self) -> Arc<dyn ExternalAuth> {
match self {
Self::Configured(provider) | Self::Runtime(provider) => Arc::clone(provider),
}
}
fn is_runtime(&self) -> bool {
matches!(self, Self::Runtime(_))
}
fn classify_provider_error(&self, error: std::io::Error) -> RefreshTokenError {
match self {
Self::Configured(provider) => provider.classify_error(error),
Self::Runtime(_) => RefreshTokenError::Transient(error),
}
}
}
fn permanent_external_auth_error(message: impl Into<String>) -> RefreshTokenError {
RefreshTokenError::Permanent(RefreshTokenFailedError::new(
RefreshTokenFailedReason::Other,
message,
))
}
impl RefreshTokenError {
pub fn failed_reason(&self) -> Option<RefreshTokenFailedReason> {
match self {
@@ -1951,7 +1988,7 @@ pub struct AuthManager {
refresh_lock: Semaphore,
agent_identity_lock: Semaphore,
agent_identity_bootstrap_cooldown: Mutex<AgentIdentityBootstrapCooldown>,
external_auth: RwLock<Option<Arc<dyn ExternalAuth>>>,
external_auth: RwLock<Option<ExternalAuthSource>>,
auth_route_config: AuthRouteConfig,
}
@@ -2197,9 +2234,9 @@ impl AuthManager {
refresh_lock: Semaphore::new(/*permits*/ 1),
agent_identity_lock: Semaphore::new(/*permits*/ 1),
agent_identity_bootstrap_cooldown: Mutex::default(),
external_auth: RwLock::new(Some(
Arc::new(BearerTokenRefresher::new(config)) as Arc<dyn ExternalAuth>
)),
external_auth: RwLock::new(Some(ExternalAuthSource::Runtime(Arc::new(
BearerTokenRefresher::new(config),
)))),
// External bearer auth refreshes by running the provider's command and never makes
// auth-owned HTTP requests, so this route is intentionally inert.
auth_route_config: AuthRouteConfig::from_http_client_factory(HttpClientFactory::new(
@@ -2361,10 +2398,12 @@ impl AuthManager {
(None, None) => true,
(Some(a), Some(b)) => match (a.api_auth_mode(), b.api_auth_mode()) {
(AuthMode::ApiKey, AuthMode::ApiKey) => a.api_key() == b.api_key(),
(AuthMode::Chatgpt, AuthMode::Chatgpt)
| (AuthMode::ChatgptAuthTokens, AuthMode::ChatgptAuthTokens) => {
(AuthMode::Chatgpt, AuthMode::Chatgpt) => {
a.get_current_auth_json() == b.get_current_auth_json()
}
(AuthMode::ChatgptAuthTokens, AuthMode::ChatgptAuthTokens) => {
a.get_current_token_data() == b.get_current_token_data()
}
(AuthMode::Headers, AuthMode::Headers) => a == b,
(AuthMode::AgentIdentity, AuthMode::AgentIdentity) => match (a, b) {
(CodexAuth::AgentIdentity(a), CodexAuth::AgentIdentity(b)) => {
@@ -2408,12 +2447,30 @@ impl AuthManager {
}
async fn load_auth(&self) -> Option<CodexAuth> {
if let Some(external_auth) = self.external_auth() {
return match self.resolve_external_auth(&external_auth).await {
if let Some(source) = self.external_auth_source() {
let cached_auth = self.auth_cached();
if !source.is_runtime()
&& cached_auth
.as_ref()
.is_some_and(|auth| self.refresh_failure_for_auth(auth).is_some())
{
return cached_auth;
}
return match self.resolve_external_auth(&source).await {
Ok(auth) => Some(auth),
Err(err) => {
tracing::error!("Failed to resolve external auth: {err}");
None
if let (Some(auth), RefreshTokenError::Permanent(error)) =
(cached_auth.as_ref(), &err)
&& !source.is_runtime()
{
self.record_permanent_refresh_failure_if_unchanged(auth, error);
}
if source.is_runtime() {
None
} else {
cached_auth
}
}
};
}
@@ -2468,17 +2525,65 @@ impl AuthManager {
&self,
external_auth: Arc<dyn ExternalAuth>,
) -> Result<(), RefreshTokenError> {
let auth = self.resolve_external_auth(&external_auth).await?;
*self.external_auth.write().map_err(|_| {
if self.has_configured_external_auth() {
return Err(permanent_external_auth_error(
"configuration-backed external auth cannot be replaced at runtime",
));
}
let external_auth_source = ExternalAuthSource::Runtime(external_auth);
let auth = self.resolve_external_auth(&external_auth_source).await?;
let mut source_slot = self.external_auth.write().map_err(|_| {
RefreshTokenError::Transient(std::io::Error::other("external auth lock is poisoned"))
})? = Some(external_auth);
self.commit_external_auth(auth)
})?;
if matches!(
source_slot.as_ref(),
Some(ExternalAuthSource::Configured(_))
) {
return Err(permanent_external_auth_error(
"configuration-backed external auth cannot be replaced at runtime",
));
}
*source_slot = Some(external_auth_source.clone());
drop(source_slot);
self.commit_external_auth(auth, &external_auth_source)
}
/// Installs an external auth source selected from process configuration at startup.
///
/// Unlike [`Self::set_external_auth`], configured auth is host-owned: runtime account APIs
/// cannot replace or clear it, and its credentials are not copied into the process-wide auth
/// store. Call this once while constructing the manager, before exposing it to clients.
pub async fn set_configured_external_auth(
&self,
external_auth: Arc<dyn ExternalAuth>,
) -> Result<(), RefreshTokenError> {
if self.has_external_auth() {
return Err(permanent_external_auth_error(
"external auth is already configured",
));
}
let external_auth_source = ExternalAuthSource::Configured(external_auth);
let auth = self.resolve_external_auth(&external_auth_source).await?;
let mut source = self
.external_auth
.write()
.map_err(|_| permanent_external_auth_error("external auth lock is poisoned"))?;
if source.is_some() {
return Err(permanent_external_auth_error(
"external auth is already configured",
));
}
*source = Some(external_auth_source.clone());
drop(source);
self.commit_external_auth(auth, &external_auth_source)
}
/// Clears only auth installed through the runtime API. Configured auth is immutable.
pub fn clear_external_auth(&self) {
if let Ok(mut external_auth) = self.external_auth.write()
&& external_auth.take().is_some()
&& matches!(external_auth.as_ref(), Some(ExternalAuthSource::Runtime(_)))
{
external_auth.take();
self.set_cached_auth(/*new_auth*/ None);
}
}
@@ -2519,7 +2624,13 @@ impl AuthManager {
}
pub fn has_external_auth(&self) -> bool {
self.external_auth().is_some()
self.external_auth_source().is_some()
}
pub fn has_configured_external_auth(&self) -> bool {
self.external_auth.read().ok().is_some_and(|source| {
matches!(source.as_ref(), Some(ExternalAuthSource::Configured(_)))
})
}
pub fn is_external_chatgpt_auth_active(&self) -> bool {
@@ -2589,11 +2700,11 @@ impl AuthManager {
UnauthorizedRecovery::new(Arc::clone(self))
}
fn external_auth(&self) -> Option<Arc<dyn ExternalAuth>> {
fn external_auth_source(&self) -> Option<ExternalAuthSource> {
self.external_auth
.read()
.ok()
.and_then(|external_auth| external_auth.as_ref().map(Arc::clone))
.and_then(|external_auth| external_auth.clone())
}
fn has_external_api_key_auth(&self) -> bool {
@@ -2606,13 +2717,14 @@ impl AuthManager {
async fn resolve_external_auth(
&self,
external_auth: &Arc<dyn ExternalAuth>,
source: &ExternalAuthSource,
) -> Result<CodexAuth, RefreshTokenError> {
let external_auth = source.provider();
let auth = external_auth
.resolve()
.await
.map_err(RefreshTokenError::Transient)?;
self.validate_external_auth(&auth)?;
.map_err(|error| source.classify_provider_error(error))?;
self.validate_external_auth(&auth, source)?;
Ok(auth)
}
@@ -2714,6 +2826,7 @@ impl AuthManager {
/// reloads the in‑memory auth cache so callers immediately observe the
/// unauthenticated state.
pub async fn logout(&self) -> std::io::Result<bool> {
self.ensure_logout_allowed()?;
let removed = logout_all_stores(
&self.codex_home,
self.auth_credentials_store_mode,
@@ -2726,6 +2839,7 @@ impl AuthManager {
}
pub async fn logout_with_revoke(&self) -> std::io::Result<bool> {
self.ensure_logout_allowed()?;
let auth_dot_json = self
.auth_cached()
.and_then(|auth| auth.get_current_auth_json());
@@ -2744,6 +2858,16 @@ impl AuthManager {
Ok(result)
}
fn ensure_logout_allowed(&self) -> std::io::Result<()> {
if self.has_configured_external_auth() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"configuration-backed external auth is managed by the host and cannot be logged out",
));
}
Ok(())
}
/// Returns the precise kind of credentials backing the current authentication.
pub fn get_api_auth_mode(&self) -> Option<AuthMode> {
self.auth_cached().as_ref().map(CodexAuth::api_auth_mode)
@@ -2787,11 +2911,12 @@ impl AuthManager {
&self,
reason: ExternalAuthRefreshReason,
) -> Result<(), RefreshTokenError> {
let Some(external_auth) = self.external_auth() else {
let Some(source) = self.external_auth_source() else {
return Err(RefreshTokenError::Transient(std::io::Error::other(
"external auth is not configured",
)));
};
let external_auth = source.provider();
let previous_account_id = self
.auth_cached()
.as_ref()
@@ -2804,14 +2929,18 @@ impl AuthManager {
let refreshed = external_auth
.refresh(context)
.await
.map_err(RefreshTokenError::Transient)?;
self.validate_external_auth(&refreshed)?;
self.commit_external_auth(refreshed)?;
.map_err(|error| source.classify_provider_error(error))?;
self.validate_external_auth(&refreshed, &source)?;
self.commit_external_auth(refreshed, &source)?;
Ok(())
}
fn commit_external_auth(&self, auth: CodexAuth) -> Result<(), RefreshTokenError> {
if auth.is_external_chatgpt_tokens() {
fn commit_external_auth(
&self,
auth: CodexAuth,
source: &ExternalAuthSource,
) -> Result<(), RefreshTokenError> {
if source.is_runtime() && auth.is_external_chatgpt_tokens() {
let auth_dot_json = auth.get_current_auth_json().ok_or_else(|| {
RefreshTokenError::Transient(std::io::Error::other(
"external ChatGPT auth tokens are missing auth state",
@@ -2832,15 +2961,24 @@ impl AuthManager {
Ok(())
}
fn validate_external_auth(&self, auth: &CodexAuth) -> Result<(), RefreshTokenError> {
fn validate_external_auth(
&self,
auth: &CodexAuth,
source: &ExternalAuthSource,
) -> Result<(), RefreshTokenError> {
let allowed_login_methods = self.allowed_login_methods();
validate_auth_restrictions(
let result = validate_auth_restrictions(
Some(&allowed_login_methods),
self.effective_chatgpt_workspaces().as_deref(),
auth,
)
.map_err(std::io::Error::other)?;
Ok(())
);
match result {
Ok(()) => Ok(()),
Err(error) if source.is_runtime() => {
Err(RefreshTokenError::Transient(std::io::Error::other(error)))
}
Err(error) => Err(permanent_external_auth_error(error)),
}
}
// Refreshes ChatGPT OAuth tokens, persists the updated auth state, and