diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 74da21769f..ef9f482c54 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1985,8 +1985,36 @@ async fn handle_unauthorized( debug.auth_error_code.as_deref(), debug.auth_error_type.as_deref(), ); - if revocation_recovery_reason.is_none() - && let Some(recovery) = auth_recovery + if let Some(recovery_reason) = revocation_recovery_reason + && let Some(recovery) = auth_recovery.as_ref() + { + let mode = recovery.mode_name(); + let phase = recovery.step_name(); + let failed = recovery.clear_invalidated_access_token_auth().await; + session_telemetry.record_auth_recovery( + mode, + phase, + "recovery_not_run", + debug.request_id.as_deref(), + debug.cf_ray.as_deref(), + debug.auth_error.as_deref(), + debug.auth_error_code.as_deref(), + Some(recovery_reason), + /*auth_state_changed*/ None, + ); + emit_feedback_auth_recovery_tags( + mode, + phase, + "recovery_not_run", + debug.request_id.as_deref(), + debug.cf_ray.as_deref(), + debug.auth_error.as_deref(), + debug.auth_error_code.as_deref(), + ); + return Err(CodexErr::RefreshTokenFailed(failed)); + } + + if let Some(recovery) = auth_recovery && recovery.has_next() { let mode = recovery.mode_name(); @@ -2064,18 +2092,17 @@ async fn handle_unauthorized( }; } - let (mode, phase, recovery_reason) = match (auth_recovery.as_ref(), revocation_recovery_reason) - { - (Some(recovery), Some(reason)) => { - (recovery.mode_name(), recovery.step_name(), Some(reason)) - } - (None, Some(reason)) => ("none", "none", Some(reason)), - (Some(recovery), None) => ( + let (mode, phase, recovery_reason) = match auth_recovery.as_ref() { + Some(recovery) => ( recovery.mode_name(), recovery.step_name(), Some(recovery.unavailable_reason()), ), - (None, None) => ("none", "none", Some("auth_manager_missing")), + None => ( + "none", + "none", + revocation_recovery_reason.or(Some("auth_manager_missing")), + ), }; session_telemetry.record_auth_recovery( mode, diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 04f237779e..dbae792cd7 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -27,6 +27,7 @@ use codex_otel::SessionTelemetry; use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::error::CodexErr; +use codex_protocol::error::RefreshTokenFailedReason; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; @@ -590,7 +591,7 @@ async fn non_chatgpt_codex_endpoints_omit_attestation_generation() { } #[tokio::test] -async fn token_invalidated_401_skips_unauthorized_recovery() { +async fn token_invalidated_401_clears_auth_and_requires_relogin() { let manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); let mut recovery = Some(manager.unauthorized_recovery()); @@ -615,13 +616,15 @@ async fn token_invalidated_401_skips_unauthorized_recovery() { .await .expect_err("revoked access tokens should not enter refresh recovery"); - let CodexErr::UnexpectedStatus(unexpected) = err else { - panic!("expected unauthorized response to bubble up, got {err:?}"); + let CodexErr::RefreshTokenFailed(failed) = err else { + panic!("expected invalidated access token to force relogin, got {err:?}"); }; + assert_eq!(failed.reason, RefreshTokenFailedReason::Revoked); assert_eq!( - unexpected.identity_error_code.as_deref(), - Some("token_invalidated") + failed.message, + "Your ChatGPT session is no longer valid. Please sign in again." ); + assert!(manager.auth_cached().is_none()); assert_eq!( recovery .as_ref() @@ -632,7 +635,7 @@ async fn token_invalidated_401_skips_unauthorized_recovery() { } #[tokio::test] -async fn token_invalidated_error_type_401_skips_unauthorized_recovery() { +async fn token_invalidated_error_type_401_clears_auth_and_requires_relogin() { let manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); let mut recovery = Some(manager.unauthorized_recovery()); @@ -650,9 +653,15 @@ async fn token_invalidated_error_type_401_skips_unauthorized_recovery() { .await .expect_err("invalidated access tokens should not enter refresh recovery"); - let CodexErr::UnexpectedStatus(_) = err else { - panic!("expected unauthorized response to bubble up, got {err:?}"); + let CodexErr::RefreshTokenFailed(failed) = err else { + panic!("expected invalidated access token to force relogin, got {err:?}"); }; + 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_eq!( recovery .as_ref() diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 63ab3e0c42..5097d0a8a9 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -324,6 +324,22 @@ async fn unauthorized_recovery_reports_mode_and_step_names() { assert_eq!(external.step_name(), "external_refresh"); } +#[tokio::test] +async fn invalidated_access_token_logout_clears_cached_auth() { + let manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let recovery = manager.unauthorized_recovery(); + + let failed = recovery.clear_invalidated_access_token_auth().await; + + 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()); +} + #[tokio::test] #[serial(codex_auth_env)] async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 06dbeb2203..95a547e9e9 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -99,6 +99,8 @@ const REFRESH_TOKEN_INVALIDATED_MESSAGE: &str = "Your access token could not be const REFRESH_TOKEN_UNKNOWN_MESSAGE: &str = "Your access token could not be refreshed. Please log out and sign in again."; const REFRESH_TOKEN_ACCOUNT_MISMATCH_MESSAGE: &str = "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again."; +const ACCESS_TOKEN_INVALIDATED_MESSAGE: &str = + "Your ChatGPT session is no longer valid. Please sign in again."; const DEFAULT_CHATGPT_BACKEND_BASE_URL: &str = "https://chatgpt.com/backend-api"; const REFRESH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; pub(super) const REVOKE_TOKEN_URL: &str = "https://auth.openai.com/oauth/revoke"; @@ -1065,7 +1067,7 @@ enum UnauthorizedRecoveryMode { // UnauthorizedRecovery is a state machine that handles an attempt to refresh the authentication when requests // to API fail with a refreshable 401 status code. Callers must leave explicit access-token revocation signals -// outside this flow. +// outside this flow so those sessions can be cleared instead of refreshed. // The client calls next() every time it encounters a 401 error, one time per retry. // For API key based authentication, we don't do anything and let the error bubble to the user. // @@ -1249,6 +1251,16 @@ impl UnauthorizedRecovery { auth_state_changed: None, }) } + + pub async fn clear_invalidated_access_token_auth(&self) -> RefreshTokenFailedError { + let message = match self.manager.logout().await { + Ok(_) => ACCESS_TOKEN_INVALIDATED_MESSAGE.to_string(), + Err(err) => format!( + "{ACCESS_TOKEN_INVALIDATED_MESSAGE} Codex could not clear saved auth: {err}" + ), + }; + RefreshTokenFailedError::new(RefreshTokenFailedReason::Revoked, message) + } } /// Central manager providing a single source of truth for auth.json derived