diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index b29be8c735..bf10c2c183 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -360,6 +360,41 @@ async fn invalidated_access_token_logout_clears_cached_auth() { assert!(!codex_home.path().join("auth.json").exists()); } +#[tokio::test] +async fn invalidated_access_token_logout_clears_cached_auth_without_account_id() { + let codex_home = tempdir().unwrap(); + write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: None, + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + let manager = AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await; + let recovery = manager.unauthorized_recovery(); + + let failed = recovery + .handle_invalidated_access_token_auth() + .await + .expect_err("unchanged revoked auth without an account id should force login"); + + assert_eq!(failed.reason, RefreshTokenFailedReason::Revoked); + assert_eq!( + failed.message, + "Your ChatGPT session is no longer valid. Please sign in again." + ); + assert!(manager.auth_cached().is_none()); + assert!(!codex_home.path().join("auth.json").exists()); +} + #[tokio::test] async fn invalidated_access_token_preserves_reloaded_auth() { let codex_home = tempdir().unwrap(); diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index ef1ef8f12e..d8c89ec846 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -1265,14 +1265,27 @@ impl UnauthorizedRecovery { pub async fn handle_invalidated_access_token_auth( &self, ) -> Result { - match self - .manager - .reload_if_account_id_matches(self.expected_account_id.as_deref()) - .await - { - ReloadOutcome::ReloadedChanged => Ok(UnauthorizedRecoveryStepResult { - auth_state_changed: Some(true), - }), + let reload_outcome = match self.expected_account_id.as_deref() { + Some(expected_account_id) => { + self.manager + .reload_if_account_id_matches(Some(expected_account_id)) + .await + } + None => self.manager.reload_if_auth_snapshot_changed().await, + }; + + match reload_outcome { + ReloadOutcome::ReloadedChanged => { + if self.manager.auth_cached().is_none() { + return Err(RefreshTokenFailedError::new( + RefreshTokenFailedReason::Revoked, + ACCESS_TOKEN_INVALIDATED_MESSAGE.to_string(), + )); + } + Ok(UnauthorizedRecoveryStepResult { + auth_state_changed: Some(true), + }) + } ReloadOutcome::ReloadedNoChange => { let message = match self.manager.logout().await { Ok(_) => ACCESS_TOKEN_INVALIDATED_MESSAGE.to_string(), @@ -1532,6 +1545,20 @@ impl AuthManager { } } + async fn reload_if_auth_snapshot_changed(&self) -> ReloadOutcome { + let new_auth = self.load_auth_from_storage().await; + let cached_before_reload = self.auth_cached(); + let auth_changed = + !Self::auths_equal_for_refresh(cached_before_reload.as_ref(), new_auth.as_ref()); + if !auth_changed { + return ReloadOutcome::ReloadedNoChange; + } + + tracing::info!("Reloading auth because the persisted auth snapshot changed."); + self.set_cached_auth(new_auth); + ReloadOutcome::ReloadedChanged + } + fn auths_equal_for_refresh(a: Option<&CodexAuth>, b: Option<&CodexAuth>) -> bool { match (a, b) { (None, None) => true,