Files
codex/codex-rs/model-provider/src/auth_refresh_tests.rs
T
cooper-oai b8f6d25ce7 Consolidate async header authentication in apply_auth_headers (#57179)
## What changed

Remove `AuthProvider::resolve_auth_headers` and use `apply_auth_headers` as the async interface for header-only authentication. Migrate providers, callers, and existing tests to the shared interface, passing an empty `HeaderMap` when only authentication headers are needed.

Document that providers may refresh credentials asynchronously and that callers must use the returned header map so providers can remove superseded credentials.

GitOrigin-RevId: 42449de6fddd2be2c50c475c8e92628493197007
2026-10-26 18:22:11 +00:00

264 lines
9.3 KiB
Rust

//! Verifies that concurrent proactive and unauthorized requests share one OAuth refresh.
use super::auth_provider_from_auth_manager;
use codex_login::AuthCredentialsStoreMode;
use codex_login::AuthHeaders;
use codex_login::AuthKeyringBackendKind;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::ExternalAuth;
use codex_login::RefreshTokenError;
use codex_login::auth::RefreshTokenFailedError;
use codex_login::auth::RefreshTokenFailedReason;
use http::HeaderMap;
use http::HeaderValue;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::task::Poll;
use tempfile::TempDir;
use tokio::sync::Barrier;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_partial_json;
use wiremock::matchers::method;
use wiremock::matchers::path;
#[tokio::test]
async fn concurrent_and_late_rejections_share_one_refresh() {
check_concurrent_refresh(RefreshTrigger::Unauthorized).await;
}
#[tokio::test]
async fn concurrent_proactive_requests_share_one_refresh() {
check_concurrent_refresh(RefreshTrigger::Proactive).await;
}
struct RotatingExternalAuth {
refreshes: AtomicUsize,
barrier: Barrier,
}
impl ExternalAuth for RotatingExternalAuth {
fn resolve(&self) -> codex_login::ExternalAuthFuture<'_, CodexAuth> {
Box::pin(async {
let token = if self.refreshes.load(Ordering::SeqCst) == 0 {
"Bearer initial"
} else {
"Bearer replacement"
};
Ok(CodexAuth::Headers(AuthHeaders::new(HeaderMap::from_iter(
[(http::header::AUTHORIZATION, HeaderValue::from_static(token))],
))))
})
}
fn refresh(
&self,
_context: codex_login::ExternalAuthRefreshContext,
) -> codex_login::ExternalAuthFuture<'_, CodexAuth> {
Box::pin(async {
if self.refreshes.fetch_add(1, Ordering::SeqCst) != 0 {
return Err(std::io::Error::other("redundant refresh rejected"));
}
self.barrier.wait().await;
self.resolve().await
})
}
fn classify_error(&self, error: std::io::Error) -> RefreshTokenError {
RefreshTokenError::Permanent(RefreshTokenFailedError::new(
RefreshTokenFailedReason::Other,
error.to_string(),
))
}
}
#[tokio::test]
async fn concurrent_external_rejections_share_one_refresh() {
let external = Arc::new(RotatingExternalAuth {
refreshes: AtomicUsize::new(0),
barrier: Barrier::new(2),
});
let initial = external.resolve().await.unwrap();
let manager = AuthManager::from_auth_for_testing(initial.clone());
manager.set_external_auth(external.clone()).await.unwrap();
let provider = auth_provider_from_auth_manager(manager, &initial);
let rejected = provider.to_auth_headers().unwrap();
let mut first = provider.refresh_after_unauthorized(rejected.clone());
let mut second = provider.refresh_after_unauthorized(rejected.clone());
std::future::poll_fn(|cx| {
assert!(first.as_mut().poll(cx).is_pending());
assert!(second.as_mut().poll(cx).is_pending());
Poll::Ready(())
})
.await;
external.barrier.wait().await;
assert_eq!(tokio::join!(first, second), (true, true));
assert!(provider.refresh_after_unauthorized(rejected).await);
assert_eq!(external.refreshes.load(Ordering::SeqCst), 1);
assert_eq!(
provider.apply_auth_headers(HeaderMap::new()).await.unwrap()[http::header::AUTHORIZATION],
"Bearer replacement"
);
}
#[derive(Clone, Copy)]
enum RefreshTrigger {
Unauthorized,
Proactive,
}
async fn check_concurrent_refresh(trigger: RefreshTrigger) {
let server = MockServer::start().await;
let home = TempDir::new().unwrap();
let auth = json!({
"tokens": {
"id_token": "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF91c2VyX2lkIjoib3JpZ2luYWwtdXNlciIsImNoYXRncHRfYWNjb3VudF9pZCI6Im9yaWdpbmFsLWFjY291bnQifX0.sig",
"access_token": "initial-token",
"refresh_token": "initial-refresh-token",
"account_id": "original-account",
"oauth_provenance": {
"issuer": server.uri(),
"client_id": "concurrent-refresh-test"
}
},
"last_refresh": match trigger {
RefreshTrigger::Unauthorized => "2099-01-01T00:00:00Z",
RefreshTrigger::Proactive => "2000-01-01T00:00:00Z",
}
});
std::fs::write(
home.path().join("auth.json"),
serde_json::to_vec(&auth).unwrap(),
)
.unwrap();
let manager = Arc::new(
AuthManager::new(
home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
codex_login::test_support::transport_default_auth_route_config(),
)
.await,
);
let provider =
auth_provider_from_auth_manager(manager.clone(), &manager.auth_cached().unwrap());
let rejected_headers = provider.to_auth_headers().unwrap();
Mock::given(method("POST"))
.and(path("/oauth/token"))
.and(body_partial_json(
json!({"refresh_token": "initial-refresh-token"}),
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"access_token": "replacement-token",
"refresh_token": "replacement-refresh-token"
})))
.expect(1)
.mount(&server)
.await;
// A redundant refresh could permanently fail even though the replacement works.
Mock::given(method("POST"))
.and(path("/oauth/token"))
.and(body_partial_json(
json!({"refresh_token": "replacement-refresh-token"}),
))
.respond_with(ResponseTemplate::new(400).set_body_json(json!({
"error": "invalid_grant",
})))
.with_priority(10)
.expect(0)
.mount(&server)
.await;
let refresh = || async {
match trigger {
RefreshTrigger::Unauthorized => {
provider
.refresh_after_unauthorized(rejected_headers.clone())
.await
}
RefreshTrigger::Proactive => {
let headers = provider.apply_auth_headers(HeaderMap::new()).await.unwrap();
assert_eq!(
headers[http::header::AUTHORIZATION],
"Bearer replacement-token"
);
true
}
}
};
let mut first = Box::pin(refresh());
let mut second = Box::pin(refresh());
let mut third = Box::pin(refresh());
// Queue both waiters before the runtime can service the first OAuth request.
std::future::poll_fn(|cx| {
assert!(first.as_mut().poll(cx).is_pending());
assert!(second.as_mut().poll(cx).is_pending());
assert!(third.as_mut().poll(cx).is_pending());
Poll::Ready(())
})
.await;
assert_eq!(tokio::join!(first, second, third), (true, true, true));
// A delayed 401 for the old token also reuses the completed refresh.
assert!(
provider
.refresh_after_unauthorized(rejected_headers.clone())
.await
);
let updated_headers = provider.apply_auth_headers(HeaderMap::new()).await.unwrap();
assert_eq!(
updated_headers[http::header::AUTHORIZATION],
"Bearer replacement-token"
);
// A rejection of the new credential must still be able to refresh again.
Mock::given(method("POST"))
.and(path("/oauth/token"))
.and(body_partial_json(
json!({"refresh_token": "replacement-refresh-token"}),
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"access_token": "next-token",
"refresh_token": "next-refresh-token"
})))
.expect(1)
.mount(&server)
.await;
assert!(provider.refresh_after_unauthorized(updated_headers).await);
assert_eq!(
provider.apply_auth_headers(HeaderMap::new()).await.unwrap()[http::header::AUTHORIZATION],
"Bearer next-token"
);
// A permanent failure must not prevent a later guarded reload of fresh credentials.
let rejected = provider.to_auth_headers().unwrap();
Mock::given(method("POST"))
.and(path("/oauth/token"))
.and(body_partial_json(
json!({"refresh_token": "next-refresh-token"}),
))
.respond_with(ResponseTemplate::new(400).set_body_json(json!({"error": "invalid_grant"})))
.expect(1)
.mount(&server)
.await;
assert!(!provider.refresh_after_unauthorized(rejected.clone()).await);
let path = home.path().join("auth.json");
let mut stored: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
stored["tokens"]["access_token"] = json!("reloaded-token");
std::fs::write(path, serde_json::to_vec(&stored).unwrap()).unwrap();
assert!(provider.refresh_after_unauthorized(rejected).await);
assert_eq!(
provider.to_auth_headers().unwrap()[http::header::AUTHORIZATION],
"Bearer reloaded-token"
);
server.verify().await;
}