mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
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:
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user