From 2468bee6593fc08fbe602a99d5033d5a3acabf50 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 10 Mar 2026 15:25:40 -0600 Subject: [PATCH 1/6] Mitigate token refresh storms --- .../app-server/src/codex_message_processor.rs | 38 +++++--- codex-rs/app-server/tests/suite/auth.rs | 90 +++++++++++++++++++ codex-rs/core/tests/suite/auth_refresh.rs | 66 ++++++++++++++ codex-rs/login/src/auth/manager.rs | 88 +++++++++++++++++- 4 files changed, 266 insertions(+), 16 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 947a49ff2f4..2787f40316a 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1338,20 +1338,25 @@ impl CodexMessageProcessor { } } - async fn refresh_token_if_requested(&self, do_refresh: bool) { + async fn refresh_token_if_requested(&self, do_refresh: bool) -> bool { if self.auth_manager.is_external_auth_active() { - return; + return false; } if do_refresh && let Err(err) = self.auth_manager.refresh_token().await { - tracing::warn!("failed to refresh token while getting account: {err}"); + let failed_reason = err.failed_reason(); + if failed_reason.is_none() { + tracing::warn!("failed to refresh token while getting account: {err}"); + } + return failed_reason.is_some(); } + false } async fn get_auth_status(&self, request_id: ConnectionRequestId, params: GetAuthStatusParams) { let include_token = params.include_token.unwrap_or(false); let do_refresh = params.refresh_token.unwrap_or(false); - self.refresh_token_if_requested(do_refresh).await; + let refresh_failed_permanently = self.refresh_token_if_requested(do_refresh).await; // Determine whether auth is required based on the active model provider. // If a custom provider is configured with `requires_openai_auth == false`, @@ -1365,18 +1370,25 @@ impl CodexMessageProcessor { requires_openai_auth: Some(false), } } else { + let refresh_failure = self.auth_manager.refresh_failure(); match self.auth_manager.auth().await { Some(auth) => { let auth_mode = auth.api_auth_mode(); - let (reported_auth_method, token_opt) = match auth.get_token() { - Ok(token) if !token.is_empty() => { - let tok = if include_token { Some(token) } else { None }; - (Some(auth_mode), tok) - } - Ok(_) => (None, None), - Err(err) => { - tracing::warn!("failed to get token for auth status: {err}"); - (None, None) + let (reported_auth_method, token_opt) = if include_token + && (refresh_failure.is_some() || refresh_failed_permanently) + { + (Some(auth_mode), None) + } else { + match auth.get_token() { + Ok(token) if !token.is_empty() => { + let tok = if include_token { Some(token) } else { None }; + (Some(auth_mode), tok) + } + Ok(_) => (None, None), + Err(err) => { + tracing::warn!("failed to get token for auth status: {err}"); + (None, None) + } } }; GetAuthStatusResponse { diff --git a/codex-rs/app-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs index 68d0bcd95d5..03668344015 100644 --- a/codex-rs/app-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -1,6 +1,8 @@ use anyhow::Result; +use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; @@ -8,10 +10,17 @@ use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::RequestId; +use codex_core::auth::AuthCredentialsStoreMode; +use codex_core::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use pretty_assertions::assert_eq; use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -207,6 +216,87 @@ async fn get_auth_status_with_api_key_no_include_token() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_auth_status_omits_token_after_permanent_refresh_failure() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("stale-access-token") + .refresh_token("stale-refresh-token") + .account_id("acct_123") + .email("user@example.com") + .plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({ + "error": { + "code": "refresh_token_reused" + } + }))) + .expect(1) + .mount(&server) + .await; + + let refresh_url = format!("{}/oauth/token", server.uri()); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ( + REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, + Some(refresh_url.as_str()), + ), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(true), + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let status: GetAuthStatusResponse = to_response(resp)?; + assert_eq!( + status, + GetAuthStatusResponse { + auth_method: Some(AuthMode::Chatgpt), + auth_token: None, + requires_openai_auth: Some(true), + } + ); + + let second_request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(true), + }) + .await?; + + let second_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_request_id)), + ) + .await??; + let second_status: GetAuthStatusResponse = to_response(second_resp)?; + assert_eq!(second_status, status); + + server.verify().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn login_api_key_rejected_when_forced_chatgpt() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/tests/suite/auth_refresh.rs b/codex-rs/core/tests/suite/auth_refresh.rs index 278124c63a4..d2daf782cdf 100644 --- a/codex-rs/core/tests/suite/auth_refresh.rs +++ b/codex-rs/core/tests/suite/auth_refresh.rs @@ -543,6 +543,72 @@ async fn refresh_token_returns_permanent_error_for_expired_refresh_token() -> Re Ok(()) } +#[serial_test::serial(auth_refresh)] +#[tokio::test] +async fn refresh_token_does_not_retry_after_permanent_failure() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": { + "code": "refresh_token_reused" + } + }))) + .expect(1) + .mount(&server) + .await; + + let ctx = RefreshTokenTestContext::new(&server)?; + let initial_last_refresh = Utc::now() - Duration::days(1); + let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN); + let initial_auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(initial_tokens.clone()), + last_refresh: Some(initial_last_refresh), + }; + ctx.write_auth(&initial_auth)?; + + let first_err = ctx + .auth_manager + .refresh_token() + .await + .err() + .context("first refresh should fail")?; + assert_eq!( + first_err.failed_reason(), + Some(RefreshTokenFailedReason::Exhausted) + ); + + let second_err = ctx + .auth_manager + .refresh_token() + .await + .err() + .context("second refresh should fail without retrying")?; + assert_eq!( + second_err.failed_reason(), + Some(RefreshTokenFailedReason::Exhausted) + ); + + let stored = ctx.load_auth()?; + assert_eq!(stored, initial_auth); + let cached_auth = ctx + .auth_manager + .auth() + .await + .context("auth should remain cached")?; + let cached = cached_auth + .get_token_data() + .context("token data should remain cached")?; + assert_eq!(cached, initial_tokens); + + server.verify().await; + Ok(()) +} + #[serial_test::serial(auth_refresh)] #[tokio::test] async fn refresh_token_returns_transient_error_on_server_failure() -> Result<()> { diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 31860cd5857..f18933e9764 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -796,6 +796,13 @@ struct CachedAuth { auth: Option, /// Callback used to refresh external auth by asking the parent app for new tokens. external_refresher: Option>, + poisoned_managed_auth: Option, +} + +#[derive(Clone, Debug)] +struct PoisonedManagedAuth { + auth_dot_json: AuthDotJson, + error: RefreshTokenFailedError, } impl Debug for CachedAuth { @@ -809,6 +816,13 @@ impl Debug for CachedAuth { "external_refresher", &self.external_refresher.as_ref().map(|_| "present"), ) + .field( + "poisoned_managed_auth", + &self + .poisoned_managed_auth + .as_ref() + .map(|poisoned| poisoned.error.reason), + ) .finish() } } @@ -1046,6 +1060,7 @@ impl AuthManager { inner: RwLock::new(CachedAuth { auth: managed_auth, external_refresher: None, + poisoned_managed_auth: None, }), enable_codex_api_key_env, auth_credentials_store_mode, @@ -1058,6 +1073,7 @@ impl AuthManager { let cached = CachedAuth { auth: Some(auth), external_refresher: None, + poisoned_managed_auth: None, }; Arc::new(Self { @@ -1074,6 +1090,7 @@ impl AuthManager { let cached = CachedAuth { auth: Some(auth), external_refresher: None, + poisoned_managed_auth: None, }; Arc::new(Self { codex_home, @@ -1089,6 +1106,11 @@ impl AuthManager { self.inner.read().ok().and_then(|c| c.auth.clone()) } + pub fn refresh_failure(&self) -> Option { + let auth = self.auth_cached()?; + self.refresh_failure_for_auth(&auth) + } + /// Current cached auth (clone). May be `None` if not logged in or load failed. /// For stale managed ChatGPT auth, first performs a guarded reload and then /// refreshes only if the on-disk auth is unchanged. @@ -1166,6 +1188,40 @@ impl AuthManager { } } + fn refresh_failure_for_auth(&self, auth: &CodexAuth) -> Option { + let auth_dot_json = auth.get_current_auth_json()?; + self.inner + .read() + .ok() + .and_then(|cached| cached.poisoned_managed_auth.clone()) + .filter(|poisoned| poisoned.auth_dot_json == auth_dot_json) + .map(|poisoned| poisoned.error) + } + + fn poison_managed_auth_if_unchanged( + &self, + attempted_auth: &CodexAuth, + error: &RefreshTokenFailedError, + ) { + let Some(attempted_auth_dot_json) = attempted_auth.get_current_auth_json() else { + return; + }; + + if let Ok(mut guard) = self.inner.write() { + let current_auth_matches = guard + .auth + .as_ref() + .and_then(CodexAuth::get_current_auth_json) + .is_some_and(|current| current == attempted_auth_dot_json); + if current_auth_matches { + guard.poisoned_managed_auth = Some(PoisonedManagedAuth { + auth_dot_json: attempted_auth_dot_json, + error: error.clone(), + }); + } + } + } + fn load_auth_from_storage(&self) -> Option { load_auth( &self.codex_home, @@ -1180,6 +1236,19 @@ impl AuthManager { if let Ok(mut guard) = self.inner.write() { let previous = guard.auth.as_ref(); let changed = !AuthManager::auths_equal(previous, new_auth.as_ref()); + let poisoned_auth_still_matches = guard + .poisoned_managed_auth + .as_ref() + .and_then(|poisoned| { + new_auth + .as_ref() + .and_then(CodexAuth::get_current_auth_json) + .map(|current| current == poisoned.auth_dot_json) + }) + .unwrap_or(false); + if !poisoned_auth_still_matches { + guard.poisoned_managed_auth = None; + } tracing::info!("Reloaded auth, changed: {changed}"); guard.auth = new_auth; changed @@ -1255,6 +1324,11 @@ impl AuthManager { /// token is the same as the cached, then ask the token authority to refresh. pub async fn refresh_token(&self) -> Result<(), RefreshTokenError> { let auth_before_reload = self.auth_cached(); + if let Some(auth_before_reload) = auth_before_reload.as_ref() + && let Some(error) = self.refresh_failure_for_auth(auth_before_reload) + { + return Err(RefreshTokenError::Permanent(error)); + } let expected_account_id = auth_before_reload .as_ref() .and_then(CodexAuth::get_account_id); @@ -1285,7 +1359,12 @@ impl AuthManager { Some(auth) => auth, None => return Ok(()), }; - match auth { + if let Some(error) = self.refresh_failure_for_auth(&auth) { + return Err(RefreshTokenError::Permanent(error)); + } + + let attempted_auth = auth.clone(); + let result = match auth { CodexAuth::ChatgptAuthTokens(_) => { self.refresh_external_auth(ExternalAuthRefreshReason::Unauthorized) .await @@ -1297,11 +1376,14 @@ impl AuthManager { )) })?; self.refresh_and_persist_chatgpt_token(&chatgpt_auth, token_data.refresh_token) - .await?; - Ok(()) + .await } CodexAuth::ApiKey(_) => Ok(()), + }; + if let Err(RefreshTokenError::Permanent(error)) = &result { + self.poison_managed_auth_if_unchanged(&attempted_auth, error); } + result } /// Log out by deleting the on‑disk auth.json (if present). Returns Ok(true) From 1306c64d98ed7cfb9576e9636a84f55a31e62339 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 10 Mar 2026 15:56:25 -0600 Subject: [PATCH 2/6] Document poisoned_managed_auth field --- .../app-server/src/codex_message_processor.rs | 25 ++++++--- codex-rs/login/src/auth/manager.rs | 53 ++++++++++--------- 2 files changed, 47 insertions(+), 31 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 2787f40316a..c73a8888c9a 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -409,6 +409,13 @@ enum EnsureConversationListenerResult { ConnectionClosed, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RefreshTokenRequestOutcome { + NotAttemptedOrSucceeded, + FailedTransiently, + FailedPermanently, +} + pub(crate) struct CodexMessageProcessorArgs { pub(crate) auth_manager: Arc, pub(crate) thread_manager: Arc, @@ -1338,25 +1345,26 @@ impl CodexMessageProcessor { } } - async fn refresh_token_if_requested(&self, do_refresh: bool) -> bool { + async fn refresh_token_if_requested(&self, do_refresh: bool) -> RefreshTokenRequestOutcome { if self.auth_manager.is_external_auth_active() { - return false; + return RefreshTokenRequestOutcome::NotAttemptedOrSucceeded; } if do_refresh && let Err(err) = self.auth_manager.refresh_token().await { let failed_reason = err.failed_reason(); if failed_reason.is_none() { tracing::warn!("failed to refresh token while getting account: {err}"); + return RefreshTokenRequestOutcome::FailedTransiently; } - return failed_reason.is_some(); + return RefreshTokenRequestOutcome::FailedPermanently; } - false + RefreshTokenRequestOutcome::NotAttemptedOrSucceeded } async fn get_auth_status(&self, request_id: ConnectionRequestId, params: GetAuthStatusParams) { let include_token = params.include_token.unwrap_or(false); let do_refresh = params.refresh_token.unwrap_or(false); - let refresh_failed_permanently = self.refresh_token_if_requested(do_refresh).await; + let refresh_outcome = self.refresh_token_if_requested(do_refresh).await; // Determine whether auth is required based on the active model provider. // If a custom provider is configured with `requires_openai_auth == false`, @@ -1375,8 +1383,11 @@ impl CodexMessageProcessor { Some(auth) => { let auth_mode = auth.api_auth_mode(); let (reported_auth_method, token_opt) = if include_token - && (refresh_failure.is_some() || refresh_failed_permanently) - { + && (refresh_failure.is_some() + || matches!( + refresh_outcome, + RefreshTokenRequestOutcome::FailedPermanently + )) { (Some(auth_mode), None) } else { match auth.get_token() { diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index f18933e9764..22665bd6ab8 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -796,11 +796,13 @@ struct CachedAuth { auth: Option, /// Callback used to refresh external auth by asking the parent app for new tokens. external_refresher: Option>, - poisoned_managed_auth: Option, + /// Permanent refresh failure cached for the current managed auth snapshot so + /// later refresh attempts for the same credentials fail fast without network. + permanent_refresh_failure: Option, } #[derive(Clone, Debug)] -struct PoisonedManagedAuth { +struct PermanentRefreshFailure { auth_dot_json: AuthDotJson, error: RefreshTokenFailedError, } @@ -817,11 +819,11 @@ impl Debug for CachedAuth { &self.external_refresher.as_ref().map(|_| "present"), ) .field( - "poisoned_managed_auth", + "permanent_refresh_failure", &self - .poisoned_managed_auth + .permanent_refresh_failure .as_ref() - .map(|poisoned| poisoned.error.reason), + .map(|failure| failure.error.reason), ) .finish() } @@ -1060,7 +1062,7 @@ impl AuthManager { inner: RwLock::new(CachedAuth { auth: managed_auth, external_refresher: None, - poisoned_managed_auth: None, + permanent_refresh_failure: None, }), enable_codex_api_key_env, auth_credentials_store_mode, @@ -1073,7 +1075,7 @@ impl AuthManager { let cached = CachedAuth { auth: Some(auth), external_refresher: None, - poisoned_managed_auth: None, + permanent_refresh_failure: None, }; Arc::new(Self { @@ -1090,7 +1092,7 @@ impl AuthManager { let cached = CachedAuth { auth: Some(auth), external_refresher: None, - poisoned_managed_auth: None, + permanent_refresh_failure: None, }; Arc::new(Self { codex_home, @@ -1108,7 +1110,7 @@ impl AuthManager { pub fn refresh_failure(&self) -> Option { let auth = self.auth_cached()?; - self.refresh_failure_for_auth(&auth) + self.permanent_refresh_failure_for_auth(&auth) } /// Current cached auth (clone). May be `None` if not logged in or load failed. @@ -1188,17 +1190,20 @@ impl AuthManager { } } - fn refresh_failure_for_auth(&self, auth: &CodexAuth) -> Option { + fn permanent_refresh_failure_for_auth( + &self, + auth: &CodexAuth, + ) -> Option { let auth_dot_json = auth.get_current_auth_json()?; self.inner .read() .ok() - .and_then(|cached| cached.poisoned_managed_auth.clone()) - .filter(|poisoned| poisoned.auth_dot_json == auth_dot_json) - .map(|poisoned| poisoned.error) + .and_then(|cached| cached.permanent_refresh_failure.clone()) + .filter(|failure| failure.auth_dot_json == auth_dot_json) + .map(|failure| failure.error) } - fn poison_managed_auth_if_unchanged( + fn record_permanent_refresh_failure_if_unchanged( &self, attempted_auth: &CodexAuth, error: &RefreshTokenFailedError, @@ -1214,7 +1219,7 @@ impl AuthManager { .and_then(CodexAuth::get_current_auth_json) .is_some_and(|current| current == attempted_auth_dot_json); if current_auth_matches { - guard.poisoned_managed_auth = Some(PoisonedManagedAuth { + guard.permanent_refresh_failure = Some(PermanentRefreshFailure { auth_dot_json: attempted_auth_dot_json, error: error.clone(), }); @@ -1236,18 +1241,18 @@ impl AuthManager { if let Ok(mut guard) = self.inner.write() { let previous = guard.auth.as_ref(); let changed = !AuthManager::auths_equal(previous, new_auth.as_ref()); - let poisoned_auth_still_matches = guard - .poisoned_managed_auth + let permanent_refresh_failure_still_matches = guard + .permanent_refresh_failure .as_ref() - .and_then(|poisoned| { + .and_then(|failure| { new_auth .as_ref() .and_then(CodexAuth::get_current_auth_json) - .map(|current| current == poisoned.auth_dot_json) + .map(|current| current == failure.auth_dot_json) }) .unwrap_or(false); - if !poisoned_auth_still_matches { - guard.poisoned_managed_auth = None; + if !permanent_refresh_failure_still_matches { + guard.permanent_refresh_failure = None; } tracing::info!("Reloaded auth, changed: {changed}"); guard.auth = new_auth; @@ -1325,7 +1330,7 @@ impl AuthManager { pub async fn refresh_token(&self) -> Result<(), RefreshTokenError> { let auth_before_reload = self.auth_cached(); if let Some(auth_before_reload) = auth_before_reload.as_ref() - && let Some(error) = self.refresh_failure_for_auth(auth_before_reload) + && let Some(error) = self.permanent_refresh_failure_for_auth(auth_before_reload) { return Err(RefreshTokenError::Permanent(error)); } @@ -1359,7 +1364,7 @@ impl AuthManager { Some(auth) => auth, None => return Ok(()), }; - if let Some(error) = self.refresh_failure_for_auth(&auth) { + if let Some(error) = self.permanent_refresh_failure_for_auth(&auth) { return Err(RefreshTokenError::Permanent(error)); } @@ -1381,7 +1386,7 @@ impl AuthManager { CodexAuth::ApiKey(_) => Ok(()), }; if let Err(RefreshTokenError::Permanent(error)) = &result { - self.poison_managed_auth_if_unchanged(&attempted_auth, error); + self.record_permanent_refresh_failure_if_unchanged(&attempted_auth, error); } result } From 91850c2c041d605c9109f6412661090652fd841c Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 10 Mar 2026 16:14:59 -0600 Subject: [PATCH 3/6] Document permanent refresh failure --- .../app-server/src/codex_message_processor.rs | 39 +++++++------- codex-rs/login/src/auth/manager.rs | 53 ++++++------------- 2 files changed, 37 insertions(+), 55 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index c73a8888c9a..01ddaac1722 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1379,29 +1379,30 @@ impl CodexMessageProcessor { } } else { let refresh_failure = self.auth_manager.refresh_failure(); + let permanent_refresh_failure = refresh_failure.is_some() + || matches!( + refresh_outcome, + RefreshTokenRequestOutcome::FailedPermanently + ); match self.auth_manager.auth().await { Some(auth) => { let auth_mode = auth.api_auth_mode(); - let (reported_auth_method, token_opt) = if include_token - && (refresh_failure.is_some() - || matches!( - refresh_outcome, - RefreshTokenRequestOutcome::FailedPermanently - )) { - (Some(auth_mode), None) - } else { - match auth.get_token() { - Ok(token) if !token.is_empty() => { - let tok = if include_token { Some(token) } else { None }; - (Some(auth_mode), tok) - } - Ok(_) => (None, None), - Err(err) => { - tracing::warn!("failed to get token for auth status: {err}"); - (None, None) + let (reported_auth_method, token_opt) = + if include_token && permanent_refresh_failure { + (Some(auth_mode), None) + } else { + match auth.get_token() { + Ok(token) if !token.is_empty() => { + let tok = if include_token { Some(token) } else { None }; + (Some(auth_mode), tok) + } + Ok(_) => (None, None), + Err(err) => { + tracing::warn!("failed to get token for auth status: {err}"); + (None, None) + } } - } - }; + }; GetAuthStatusResponse { auth_method: reported_auth_method, auth_token: token_opt, diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 22665bd6ab8..6ba8150fff0 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -796,15 +796,9 @@ struct CachedAuth { auth: Option, /// Callback used to refresh external auth by asking the parent app for new tokens. external_refresher: Option>, - /// Permanent refresh failure cached for the current managed auth snapshot so + /// Permanent refresh failure cached for the current auth snapshot so /// later refresh attempts for the same credentials fail fast without network. - permanent_refresh_failure: Option, -} - -#[derive(Clone, Debug)] -struct PermanentRefreshFailure { - auth_dot_json: AuthDotJson, - error: RefreshTokenFailedError, + permanent_refresh_failure: Option, } impl Debug for CachedAuth { @@ -823,7 +817,7 @@ impl Debug for CachedAuth { &self .permanent_refresh_failure .as_ref() - .map(|failure| failure.error.reason), + .map(|failure| failure.reason), ) .finish() } @@ -1190,39 +1184,34 @@ impl AuthManager { } } + /// Returns the cached permanent refresh failure only when `auth` still + /// matches the current cached auth snapshot. fn permanent_refresh_failure_for_auth( &self, auth: &CodexAuth, ) -> Option { - let auth_dot_json = auth.get_current_auth_json()?; self.inner .read() .ok() .and_then(|cached| cached.permanent_refresh_failure.clone()) - .filter(|failure| failure.auth_dot_json == auth_dot_json) - .map(|failure| failure.error) + .filter(|_| { + let current_auth = self.auth_cached(); + Self::auths_equal_for_refresh(Some(auth), current_auth.as_ref()) + }) } + /// Records a permanent refresh failure only if the failed refresh was + /// attempted against the auth snapshot that is still cached. fn record_permanent_refresh_failure_if_unchanged( &self, attempted_auth: &CodexAuth, error: &RefreshTokenFailedError, ) { - let Some(attempted_auth_dot_json) = attempted_auth.get_current_auth_json() else { - return; - }; - if let Ok(mut guard) = self.inner.write() { - let current_auth_matches = guard - .auth - .as_ref() - .and_then(CodexAuth::get_current_auth_json) - .is_some_and(|current| current == attempted_auth_dot_json); + let current_auth_matches = + Self::auths_equal_for_refresh(Some(attempted_auth), guard.auth.as_ref()); if current_auth_matches { - guard.permanent_refresh_failure = Some(PermanentRefreshFailure { - auth_dot_json: attempted_auth_dot_json, - error: error.clone(), - }); + guard.permanent_refresh_failure = Some(error.clone()); } } } @@ -1241,17 +1230,9 @@ impl AuthManager { if let Ok(mut guard) = self.inner.write() { let previous = guard.auth.as_ref(); let changed = !AuthManager::auths_equal(previous, new_auth.as_ref()); - let permanent_refresh_failure_still_matches = guard - .permanent_refresh_failure - .as_ref() - .and_then(|failure| { - new_auth - .as_ref() - .and_then(CodexAuth::get_current_auth_json) - .map(|current| current == failure.auth_dot_json) - }) - .unwrap_or(false); - if !permanent_refresh_failure_still_matches { + let auth_changed_for_refresh = + !Self::auths_equal_for_refresh(previous, new_auth.as_ref()); + if auth_changed_for_refresh { guard.permanent_refresh_failure = None; } tracing::info!("Reloaded auth, changed: {changed}"); From 40f642fa740b14eb2b32c481f98a036635bbcb10 Mon Sep 17 00:00:00 2001 From: celia-oai Date: Mon, 23 Mar 2026 15:02:44 -0700 Subject: [PATCH 4/6] changes --- .../app-server/src/codex_message_processor.rs | 11 +- codex-rs/app-server/tests/suite/auth.rs | 203 ++++++++++++++++++ codex-rs/core/tests/suite/auth_refresh.rs | 81 +++++++ codex-rs/login/src/auth/manager.rs | 7 +- 4 files changed, 293 insertions(+), 9 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 01ddaac1722..cc4a689d171 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1378,14 +1378,13 @@ impl CodexMessageProcessor { requires_openai_auth: Some(false), } } else { - let refresh_failure = self.auth_manager.refresh_failure(); - let permanent_refresh_failure = refresh_failure.is_some() - || matches!( - refresh_outcome, - RefreshTokenRequestOutcome::FailedPermanently - ); match self.auth_manager.auth().await { Some(auth) => { + let permanent_refresh_failure = self.auth_manager.refresh_failure().is_some() + || matches!( + refresh_outcome, + RefreshTokenRequestOutcome::FailedPermanently + ); let auth_mode = auth.api_auth_mode(); let (reported_auth_method, token_opt) = if include_token && permanent_refresh_failure { diff --git a/codex-rs/app-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs index 03668344015..ff50cd744ae 100644 --- a/codex-rs/app-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -3,6 +3,8 @@ use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; +use chrono::Duration; +use chrono::Utc; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; @@ -216,6 +218,40 @@ async fn get_auth_status_with_api_key_no_include_token() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_auth_status_with_api_key_refresh_requested() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + login_with_api_key_via_request(&mut mcp, "sk-test-key").await?; + + let request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(true), + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let status: GetAuthStatusResponse = to_response(resp)?; + assert_eq!( + status, + GetAuthStatusResponse { + auth_method: Some(AuthMode::ApiKey), + auth_token: Some("sk-test-key".to_string()), + requires_openai_auth: Some(true), + } + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_auth_status_omits_token_after_permanent_refresh_failure() -> Result<()> { let codex_home = TempDir::new()?; @@ -297,6 +333,173 @@ async fn get_auth_status_omits_token_after_permanent_refresh_failure() -> Result Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_auth_status_omits_token_after_proactive_refresh_failure() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("stale-access-token") + .refresh_token("stale-refresh-token") + .account_id("acct_123") + .email("user@example.com") + .plan_type("pro") + .last_refresh(Some(Utc::now() - Duration::days(9))), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({ + "error": { + "code": "refresh_token_reused" + } + }))) + .expect(2) + .mount(&server) + .await; + + let refresh_url = format!("{}/oauth/token", server.uri()); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ( + REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, + Some(refresh_url.as_str()), + ), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(false), + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let status: GetAuthStatusResponse = to_response(resp)?; + assert_eq!( + status, + GetAuthStatusResponse { + auth_method: Some(AuthMode::Chatgpt), + auth_token: None, + requires_openai_auth: Some(true), + } + ); + + server.verify().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_auth_status_returns_token_after_proactive_refresh_recovery() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("stale-access-token") + .refresh_token("stale-refresh-token") + .account_id("acct_123") + .email("user@example.com") + .plan_type("pro") + .last_refresh(Some(Utc::now() - Duration::days(9))), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({ + "error": { + "code": "refresh_token_reused" + } + }))) + .expect(2) + .mount(&server) + .await; + + let refresh_url = format!("{}/oauth/token", server.uri()); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ( + REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, + Some(refresh_url.as_str()), + ), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let failed_request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(true), + }) + .await?; + + let failed_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(failed_request_id)), + ) + .await??; + let failed_status: GetAuthStatusResponse = to_response(failed_resp)?; + assert_eq!( + failed_status, + GetAuthStatusResponse { + auth_method: Some(AuthMode::Chatgpt), + auth_token: None, + requires_openai_auth: Some(true), + } + ); + + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("recovered-access-token") + .refresh_token("recovered-refresh-token") + .account_id("acct_123") + .email("user@example.com") + .plan_type("pro") + .last_refresh(Some(Utc::now())), + AuthCredentialsStoreMode::File, + )?; + + let recovered_request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(false), + }) + .await?; + + let recovered_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(recovered_request_id)), + ) + .await??; + let recovered_status: GetAuthStatusResponse = to_response(recovered_resp)?; + assert_eq!( + recovered_status, + GetAuthStatusResponse { + auth_method: Some(AuthMode::Chatgpt), + auth_token: Some("recovered-access-token".to_string()), + requires_openai_auth: Some(true), + } + ); + + server.verify().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn login_api_key_rejected_when_forced_chatgpt() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/tests/suite/auth_refresh.rs b/codex-rs/core/tests/suite/auth_refresh.rs index d2daf782cdf..0acc24cabe8 100644 --- a/codex-rs/core/tests/suite/auth_refresh.rs +++ b/codex-rs/core/tests/suite/auth_refresh.rs @@ -609,6 +609,87 @@ async fn refresh_token_does_not_retry_after_permanent_failure() -> Result<()> { Ok(()) } +#[serial_test::serial(auth_refresh)] +#[tokio::test] +async fn refresh_token_reloads_changed_auth_after_permanent_failure() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": { + "code": "refresh_token_reused" + } + }))) + .expect(1) + .mount(&server) + .await; + + let ctx = RefreshTokenTestContext::new(&server)?; + let initial_last_refresh = Utc::now() - Duration::days(1); + let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN); + let initial_auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(initial_tokens.clone()), + last_refresh: Some(initial_last_refresh), + }; + ctx.write_auth(&initial_auth)?; + + let first_err = ctx + .auth_manager + .refresh_token() + .await + .err() + .context("first refresh should fail")?; + assert_eq!( + first_err.failed_reason(), + Some(RefreshTokenFailedReason::Exhausted) + ); + + let fresh_refresh = Utc::now() - Duration::hours(1); + let disk_tokens = build_tokens("disk-access-token", "disk-refresh-token"); + let disk_auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(disk_tokens.clone()), + last_refresh: Some(fresh_refresh), + }; + save_auth( + ctx.codex_home.path(), + &disk_auth, + AuthCredentialsStoreMode::File, + )?; + + ctx.auth_manager + .refresh_token() + .await + .context("refresh should reload changed auth without retrying")?; + + let stored = ctx.load_auth()?; + assert_eq!(stored, disk_auth); + + let cached_auth = ctx + .auth_manager + .auth_cached() + .context("auth should be cached")?; + let cached = cached_auth + .get_token_data() + .context("token data should reload from disk")?; + assert_eq!(cached, disk_tokens); + + let requests = server.received_requests().await.unwrap_or_default(); + assert_eq!( + requests.len(), + 1, + "expected only the initial refresh request" + ); + + server.verify().await; + Ok(()) +} + #[serial_test::serial(auth_refresh)] #[tokio::test] async fn refresh_token_returns_transient_error_on_server_failure() -> Result<()> { diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 6ba8150fff0..984981698c7 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -1310,10 +1310,11 @@ impl AuthManager { /// token is the same as the cached, then ask the token authority to refresh. pub async fn refresh_token(&self) -> Result<(), RefreshTokenError> { let auth_before_reload = self.auth_cached(); - if let Some(auth_before_reload) = auth_before_reload.as_ref() - && let Some(error) = self.permanent_refresh_failure_for_auth(auth_before_reload) + if auth_before_reload + .as_ref() + .is_some_and(CodexAuth::is_api_key_auth) { - return Err(RefreshTokenError::Permanent(error)); + return Ok(()); } let expected_account_id = auth_before_reload .as_ref() From 1a8093fb4e848faf334ee1e6cf36a53b09da4911 Mon Sep 17 00:00:00 2001 From: celia-oai Date: Tue, 24 Mar 2026 10:46:50 -0700 Subject: [PATCH 5/6] fix --- codex-rs/login/src/auth/manager.rs | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 984981698c7..8af2ea35d54 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -1103,8 +1103,10 @@ impl AuthManager { } pub fn refresh_failure(&self) -> Option { - let auth = self.auth_cached()?; - self.permanent_refresh_failure_for_auth(&auth) + self.inner + .read() + .ok() + .and_then(|cached| cached.permanent_refresh_failure.clone()) } /// Current cached auth (clone). May be `None` if not logged in or load failed. @@ -1184,22 +1186,6 @@ impl AuthManager { } } - /// Returns the cached permanent refresh failure only when `auth` still - /// matches the current cached auth snapshot. - fn permanent_refresh_failure_for_auth( - &self, - auth: &CodexAuth, - ) -> Option { - self.inner - .read() - .ok() - .and_then(|cached| cached.permanent_refresh_failure.clone()) - .filter(|_| { - let current_auth = self.auth_cached(); - Self::auths_equal_for_refresh(Some(auth), current_auth.as_ref()) - }) - } - /// Records a permanent refresh failure only if the failed refresh was /// attempted against the auth snapshot that is still cached. fn record_permanent_refresh_failure_if_unchanged( @@ -1346,7 +1332,7 @@ impl AuthManager { Some(auth) => auth, None => return Ok(()), }; - if let Some(error) = self.permanent_refresh_failure_for_auth(&auth) { + if let Some(error) = self.refresh_failure() { return Err(RefreshTokenError::Permanent(error)); } From e24050977899b6ddb7b3ddfc4a95226baaf46950 Mon Sep 17 00:00:00 2001 From: celia-oai Date: Tue, 24 Mar 2026 11:11:51 -0700 Subject: [PATCH 6/6] fix --- .../app-server/src/codex_message_processor.rs | 9 ++-- codex-rs/login/src/auth/auth_tests.rs | 43 +++++++++++++++++++ codex-rs/login/src/auth/manager.rs | 30 +++++++++---- 3 files changed, 67 insertions(+), 15 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 57d3f4fbe08..e7b41c04cb5 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1364,7 +1364,7 @@ impl CodexMessageProcessor { let include_token = params.include_token.unwrap_or(false); let do_refresh = params.refresh_token.unwrap_or(false); - let refresh_outcome = self.refresh_token_if_requested(do_refresh).await; + self.refresh_token_if_requested(do_refresh).await; // Determine whether auth is required based on the active model provider. // If a custom provider is configured with `requires_openai_auth == false`, @@ -1380,11 +1380,8 @@ impl CodexMessageProcessor { } else { match self.auth_manager.auth().await { Some(auth) => { - let permanent_refresh_failure = self.auth_manager.refresh_failure().is_some() - || matches!( - refresh_outcome, - RefreshTokenRequestOutcome::FailedPermanently - ); + let permanent_refresh_failure = + self.auth_manager.refresh_failure_for_auth(&auth).is_some(); let auth_mode = auth.api_auth_mode(); let (reported_auth_method, token_opt) = if include_token && permanent_refresh_failure { diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index f9fb58a9d5f..60511caa4da 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -197,6 +197,49 @@ fn unauthorized_recovery_reports_mode_and_step_names() { assert_eq!(external.step_name(), "external_refresh"); } +#[test] +fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { + let codex_home = tempdir().unwrap(); + write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: Some("org_mine".to_string()), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File) + .expect("load auth") + .expect("auth available"); + let mut updated_auth_dot_json = auth + .get_current_auth_json() + .expect("AuthDotJson should exist"); + let updated_tokens = updated_auth_dot_json + .tokens + .as_mut() + .expect("tokens should exist"); + updated_tokens.access_token = "new-access-token".to_string(); + updated_tokens.refresh_token = "new-refresh-token".to_string(); + let updated_auth = CodexAuth::from_auth_dot_json( + codex_home.path(), + updated_auth_dot_json, + AuthCredentialsStoreMode::File, + ) + .expect("updated auth should parse"); + + let manager = AuthManager::from_auth_for_testing(auth.clone()); + let error = RefreshTokenFailedError::new( + RefreshTokenFailedReason::Exhausted, + "refresh token already used", + ); + manager.record_permanent_refresh_failure_if_unchanged(&auth, &error); + + assert_eq!(manager.refresh_failure_for_auth(&auth), Some(error)); + assert_eq!(manager.refresh_failure_for_auth(&updated_auth), None); +} + struct AuthFileParams { openai_api_key: Option, chatgpt_plan_type: Option, diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 8af2ea35d54..8508f99fa32 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -798,7 +798,13 @@ struct CachedAuth { external_refresher: Option>, /// Permanent refresh failure cached for the current auth snapshot so /// later refresh attempts for the same credentials fail fast without network. - permanent_refresh_failure: Option, + permanent_refresh_failure: Option, +} + +#[derive(Clone)] +struct AuthScopedRefreshFailure { + auth: CodexAuth, + error: RefreshTokenFailedError, } impl Debug for CachedAuth { @@ -817,7 +823,7 @@ impl Debug for CachedAuth { &self .permanent_refresh_failure .as_ref() - .map(|failure| failure.reason), + .map(|failure| failure.error.reason), ) .finish() } @@ -1102,11 +1108,14 @@ impl AuthManager { self.inner.read().ok().and_then(|c| c.auth.clone()) } - pub fn refresh_failure(&self) -> Option { - self.inner - .read() - .ok() - .and_then(|cached| cached.permanent_refresh_failure.clone()) + pub fn refresh_failure_for_auth(&self, auth: &CodexAuth) -> Option { + self.inner.read().ok().and_then(|cached| { + cached + .permanent_refresh_failure + .as_ref() + .filter(|failure| Self::auths_equal_for_refresh(Some(auth), Some(&failure.auth))) + .map(|failure| failure.error.clone()) + }) } /// Current cached auth (clone). May be `None` if not logged in or load failed. @@ -1197,7 +1206,10 @@ impl AuthManager { let current_auth_matches = Self::auths_equal_for_refresh(Some(attempted_auth), guard.auth.as_ref()); if current_auth_matches { - guard.permanent_refresh_failure = Some(error.clone()); + guard.permanent_refresh_failure = Some(AuthScopedRefreshFailure { + auth: attempted_auth.clone(), + error: error.clone(), + }); } } } @@ -1332,7 +1344,7 @@ impl AuthManager { Some(auth) => auth, None => return Ok(()), }; - if let Some(error) = self.refresh_failure() { + if let Some(error) = self.refresh_failure_for_auth(&auth) { return Err(RefreshTokenError::Permanent(error)); }