[codex-core] guard invalidated token logout [ci changed_files]

This commit is contained in:
Cooper Gamble
2026-05-19 22:48:06 +00:00
parent 8f13e709e6
commit 27acff776c
5 changed files with 250 additions and 42 deletions

View File

@@ -446,7 +446,7 @@ async fn external_auth_refreshes_on_unauthorized() -> Result<()> {
responses::ev_completed("resp-turn"),
]);
let unauthorized = ResponseTemplate::new(401).set_body_json(json!({
"error": { "message": "unauthorized" }
"error": { "message": "unauthorized", "type": "token_invalidated" }
}));
let responses_mock = responses::mount_response_sequence(
&mock_server,

View File

@@ -1987,31 +1987,58 @@ async fn handle_unauthorized(
);
if let Some(recovery_reason) = revocation_recovery_reason
&& let Some(recovery) = auth_recovery.as_ref()
&& recovery.handles_invalidated_access_token_auth()
{
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));
return match recovery.handle_invalidated_access_token_auth().await {
Ok(step_result) => {
session_telemetry.record_auth_recovery(
mode,
phase,
"recovery_succeeded",
debug.request_id.as_deref(),
debug.cf_ray.as_deref(),
debug.auth_error.as_deref(),
debug.auth_error_code.as_deref(),
Some(recovery_reason),
step_result.auth_state_changed(),
);
emit_feedback_auth_recovery_tags(
mode,
phase,
"recovery_succeeded",
debug.request_id.as_deref(),
debug.cf_ray.as_deref(),
debug.auth_error.as_deref(),
debug.auth_error_code.as_deref(),
);
Ok(UnauthorizedRecoveryExecution { mode, phase })
}
Err(failed) => {
session_telemetry.record_auth_recovery(
mode,
phase,
"recovery_failed_permanent",
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_failed_permanent",
debug.request_id.as_deref(),
debug.cf_ray.as_deref(),
debug.auth_error.as_deref(),
debug.auth_error_code.as_deref(),
);
Err(CodexErr::RefreshTokenFailed(failed))
}
};
}
if let Some(recovery) = auth_recovery

View File

@@ -17,7 +17,6 @@ use codex_api::ResponseEvent;
use codex_api::TransportError;
use codex_app_server_protocol::AuthMode;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_model_provider::BearerAuthProvider;
use codex_model_provider_info::CHATGPT_CODEX_BASE_URL;
use codex_model_provider_info::ModelProviderInfo;
@@ -49,6 +48,7 @@ use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
use std::collections::VecDeque;
use std::path::Path;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::Mutex;
@@ -131,6 +131,53 @@ fn test_session_telemetry() -> SessionTelemetry {
)
}
fn write_managed_chatgpt_auth(codex_home: &Path, access_token: &str) {
let encode_json = |value: serde_json::Value| {
base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&value).expect("managed auth JWT segment should serialize"))
};
let id_token = format!(
"{}.{}.{}",
encode_json(json!({"alg": "none", "typ": "JWT"})),
encode_json(json!({
"email": "user@example.com",
"https://api.openai.com/auth": {
"chatgpt_account_id": "account_id",
"chatgpt_user_id": "user-12345",
"user_id": "user-12345"
}
})),
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"sig"),
);
let auth_json = json!({
"tokens": {
"id_token": id_token,
"access_token": access_token,
"refresh_token": "test-refresh-token",
"account_id": "account_id"
},
"last_refresh": chrono::Utc::now(),
});
std::fs::write(
codex_home.join("auth.json"),
serde_json::to_vec_pretty(&auth_json).expect("managed auth should serialize"),
)
.expect("managed auth should persist");
}
async fn managed_chatgpt_auth_manager(access_token: &str) -> (TempDir, Arc<AuthManager>) {
let codex_home = TempDir::new().expect("managed auth tempdir");
write_managed_chatgpt_auth(codex_home.path(), access_token);
let manager = AuthManager::shared(
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
codex_login::AuthCredentialsStoreMode::File,
/*chatgpt_base_url*/ None,
)
.await;
(codex_home, manager)
}
#[derive(Default)]
struct TagCollectorVisitor {
tags: BTreeMap<String, String>,
@@ -592,8 +639,7 @@ async fn non_chatgpt_codex_endpoints_omit_attestation_generation() {
#[tokio::test]
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 (_codex_home, manager) = managed_chatgpt_auth_manager("revoked-access-token").await;
let mut recovery = Some(manager.unauthorized_recovery());
let x_error_json = base64::engine::general_purpose::STANDARD
.encode(r#"{"error":{"code":"token_invalidated"}}"#);
@@ -636,8 +682,7 @@ async fn token_invalidated_401_clears_auth_and_requires_relogin() {
#[tokio::test]
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 (_codex_home, manager) = managed_chatgpt_auth_manager("revoked-access-token").await;
let mut recovery = Some(manager.unauthorized_recovery());
let err = handle_unauthorized(
@@ -670,3 +715,34 @@ async fn token_invalidated_error_type_401_clears_auth_and_requires_relogin() {
"reload"
);
}
#[tokio::test]
async fn token_invalidated_401_retries_when_persisted_auth_changed() {
let (codex_home, manager) = managed_chatgpt_auth_manager("revoked-access-token").await;
write_managed_chatgpt_auth(codex_home.path(), "replacement-access-token");
let mut recovery = Some(manager.unauthorized_recovery());
let retry = handle_unauthorized(
TransportError::Http {
status: StatusCode::UNAUTHORIZED,
url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()),
headers: None,
body: Some(r#"{"error":{"type":"token_invalidated"}}"#.to_string()),
},
&mut recovery,
&test_session_telemetry(),
)
.await
.expect("new persisted auth should retry instead of logging out");
assert_eq!(retry.mode, "managed");
assert_eq!(retry.phase, "reload");
assert_eq!(
manager
.auth_cached()
.expect("replacement auth should remain cached")
.get_token()
.expect("replacement token should resolve"),
"replacement-access-token"
);
}

View File

@@ -326,11 +326,30 @@ async fn unauthorized_recovery_reports_mode_and_step_names() {
#[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 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 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.clear_invalidated_access_token_auth().await;
assert!(recovery.handles_invalidated_access_token_auth());
let failed = recovery
.handle_invalidated_access_token_auth()
.await
.expect_err("unchanged revoked auth should force login");
assert_eq!(failed.reason, RefreshTokenFailedReason::Revoked);
assert_eq!(
@@ -338,6 +357,60 @@ async fn invalidated_access_token_logout_clears_cached_auth() {
"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();
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 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 mut reauthenticated = load_auth_dot_json(codex_home.path(), AuthCredentialsStoreMode::File)
.expect("auth should load")
.expect("auth should exist");
reauthenticated
.tokens
.as_mut()
.expect("tokens should exist")
.access_token = "replacement-access-token".to_string();
save_auth(
codex_home.path(),
&reauthenticated,
AuthCredentialsStoreMode::File,
)
.expect("replacement auth should persist");
let step_result = recovery
.handle_invalidated_access_token_auth()
.await
.expect("new persisted auth should be retried");
assert_eq!(step_result.auth_state_changed(), Some(true));
assert_eq!(
manager
.auth_cached()
.expect("replacement auth should remain cached")
.get_token()
.expect("replacement token should resolve"),
"replacement-access-token"
);
assert!(codex_home.path().join("auth.json").exists());
}
#[tokio::test]
@@ -469,6 +542,7 @@ async fn unauthorized_recovery_uses_external_refresh_for_bearer_manager() {
assert!(recovery.has_next());
assert_eq!(recovery.mode_name(), "external");
assert_eq!(recovery.step_name(), "external_refresh");
assert!(!recovery.handles_invalidated_access_token_auth());
let result = recovery
.next()
@@ -626,7 +700,8 @@ fn write_auth_file(params: AuthFileParams, codex_home: &Path) -> std::io::Result
"tokens": {
"id_token": fake_jwt,
"access_token": "test-access-token",
"refresh_token": "test-refresh-token"
"refresh_token": "test-refresh-token",
"account_id": params.chatgpt_account_id,
},
"last_refresh": Utc::now(),
});

View File

@@ -1066,8 +1066,9 @@ 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 so those sessions can be cleared instead of refreshed.
// to API fail with a refreshable 401 status code. Managed ChatGPT access-token revocation is handled beside
// this flow so persisted credentials can be guarded before they are cleared; externally owned auth stays here
// so its owner can supply replacement credentials.
// 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.
//
@@ -1193,6 +1194,15 @@ impl UnauthorizedRecovery {
}
}
pub fn handles_invalidated_access_token_auth(&self) -> bool {
self.mode == UnauthorizedRecoveryMode::Managed
&& self
.manager
.auth_cached()
.as_ref()
.is_some_and(|auth| matches!(auth, CodexAuth::Chatgpt(_)))
}
pub async fn next(&mut self) -> Result<UnauthorizedRecoveryStepResult, RefreshTokenError> {
if !self.has_next() {
return Err(RefreshTokenError::Permanent(RefreshTokenFailedError::new(
@@ -1252,14 +1262,34 @@ impl UnauthorizedRecovery {
})
}
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)
pub async fn handle_invalidated_access_token_auth(
&self,
) -> Result<UnauthorizedRecoveryStepResult, RefreshTokenFailedError> {
match self
.manager
.reload_if_account_id_matches(self.expected_account_id.as_deref())
.await
{
ReloadOutcome::ReloadedChanged => Ok(UnauthorizedRecoveryStepResult {
auth_state_changed: Some(true),
}),
ReloadOutcome::ReloadedNoChange => {
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}"
),
};
Err(RefreshTokenFailedError::new(
RefreshTokenFailedReason::Revoked,
message,
))
}
ReloadOutcome::Skipped => Err(RefreshTokenFailedError::new(
RefreshTokenFailedReason::Other,
REFRESH_TOKEN_ACCOUNT_MISMATCH_MESSAGE.to_string(),
)),
}
}
}