[codex-core] clear invalidated ChatGPT auth [ci changed_files]

This commit is contained in:
Cooper Gamble
2026-05-21 23:26:50 +00:00
parent 41b65786aa
commit 8f13e709e6
4 changed files with 83 additions and 19 deletions
+37 -10
View File
@@ -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,
+17 -8
View File
@@ -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()
+16
View File
@@ -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() {
+13 -1
View File
@@ -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