From 5e6cbbadf79671b7ff8e592ca4aad05f8d22a499 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Tue, 28 Apr 2026 16:15:47 -0700 Subject: [PATCH] Return None when auth refresh fails (#20092) Right now, if Codex winds up in a state with auth but it can't refresh the token, the user is left with an unhelpful message that says to log out and log back in again. Ultimately, we should prevent that from happening but if it does, returning None will allow the caller to redirect the user back to the login page --- codex-rs/app-server/tests/suite/v2/account.rs | 89 +++++++++++++++++++ codex-rs/model-provider/src/provider.rs | 8 +- 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 2d75fd10a..50c365d63 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -8,6 +8,8 @@ use app_test_support::ChatGptIdTokenClaims; use app_test_support::encode_id_token; use app_test_support::write_chatgpt_auth; use app_test_support::write_models_cache; +use chrono::Duration as ChronoDuration; +use chrono::Utc; use codex_app_server_protocol::Account; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::CancelLoginAccountParams; @@ -17,6 +19,8 @@ use codex_app_server_protocol::ChatgptAuthTokensRefreshReason; use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAccountResponse; +use codex_app_server_protocol::GetAuthStatusParams; +use codex_app_server_protocol::GetAuthStatusResponse; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::JSONRPCNotification; @@ -29,6 +33,7 @@ use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnStatus; use codex_config::types::AuthCredentialsStoreMode; +use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_login::login_with_api_key; use codex_protocol::account::PlanType as AccountPlanType; use core_test_support::responses; @@ -1643,6 +1648,90 @@ async fn get_account_with_chatgpt() -> Result<()> { Ok(()) } +#[tokio::test] +async fn get_account_omits_chatgpt_after_permanent_refresh_failure() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + ..Default::default() + }, + )?; + 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() - ChronoDuration::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(1..=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 auth_status_request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(true), + }) + .await?; + let auth_status_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(auth_status_request_id)), + ) + .await??; + let _: GetAuthStatusResponse = to_response(auth_status_resp)?; + + let request_id = mcp + .send_get_account_request(GetAccountParams { + refresh_token: false, + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountResponse = to_response(resp)?; + + assert_eq!( + received, + GetAccountResponse { + account: None, + requires_openai_auth: true, + } + ); + server.verify().await; + Ok(()) +} + #[tokio::test] async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index b845aae5b..9027fa3cb 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -148,7 +148,13 @@ impl ModelProvider for ConfiguredModelProvider { let account = if self.info.requires_openai_auth { self.auth_manager .as_ref() - .and_then(|auth_manager| auth_manager.auth_cached()) + .and_then(|auth_manager| { + let auth = auth_manager.auth_cached()?; + if auth_manager.refresh_failure_for_auth(&auth).is_some() { + return None; + } + Some(auth) + }) .map(|auth| match &auth { CodexAuth::ApiKey(_) => Ok(ProviderAccount::ApiKey), CodexAuth::Chatgpt(_)