mirror of
https://github.com/openai/codex.git
synced 2026-10-31 19:28:49 +00:00
## 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
264 lines
9.3 KiB
Rust
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;
|
|
}
|