From d1aaf789ade6b0f20d746e7fa7ac8a7520798456 Mon Sep 17 00:00:00 2001 From: cooper-oai Date: Fri, 12 Jun 2026 12:38:30 -0700 Subject: [PATCH] [login] revoke existing auth before starting login (#27674) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why `codex login` previously persisted newly issued OAuth credentials and only then attempted to revoke the superseded refresh token. The old credential must be revoked before a replacement browser or device-code flow starts, and successful login must not perform any post-login revocation attempt. ## What changed - Revoke and clear existing stored auth before browser or device-code CLI login begins. - Remove superseded-token detection and revocation from the shared token persistence path; successful login now only saves the new credentials. - Read the raw configured auth store during CLI cleanup so environment-provided auth cannot mask the stored refresh token. - Preserve `auto` storage fallback semantics when keyring deletion fails by clearing the fallback auth file. - Add a process-level CLI regression test that requires the revoke request to precede every device-login request and occur exactly once. If replacement login is canceled or fails, the previous local credentials have already been cleared. Remote revocation remains best effort, matching explicit logout behavior. ## Validation ### Process-level before/after reproduction I compiled the real `codex` CLI from the pre-fix parent (`14df0e8833`) and from the PR implementation (`25c002f23b`; the login behavior is unchanged at the current head), then ran the same device-code flow against a local HTTP mock OAuth authority. Each run: 1. Used a fresh temporary `CODEX_HOME` configured with `cli_auth_credentials_store = "file"`. 2. Seeded that temporary home with managed ChatGPT auth containing `old-access` and `old-refresh` tokens. 3. Pointed `CODEX_REVOKE_TOKEN_URL_OVERRIDE` at the mock `/oauth/revoke` endpoint. 4. Ran the compiled CLI as: ```shell CODEX_HOME= \ CODEX_REVOKE_TOKEN_URL_OVERRIDE=/oauth/revoke \ login --device-auth --experimental_issuer ``` 5. Recorded every request received by the mock authority. The mock marked `new-access` valid when `/oauth/token` issued it and invalidated it if `/oauth/revoke` arrived afterward, reproducing the observed session-invalidating failure mode. After login exited, the harness also verified the persisted refresh token and probed a protected endpoint with `new-access`. | Build | Observed request order | CLI/persistence result | `new-access` probe | | --- | --- | --- | --- | | Pre-fix | `usercode → device token → OAuth token → revoke(old-refresh)` | Exit `0`; `new-refresh` persisted | `401` | | PR | `revoke(old-refresh) → usercode → device token → OAuth token` | Exit `0`; `new-refresh` persisted | `200` | The PR run therefore issued exactly one revocation request, before any request that initiated the replacement login, and issued no revocation after token exchange. ### Regression coverage `codex-rs/cli/tests/login.rs::device_login_revokes_existing_auth_before_requesting_new_tokens` runs the real first-party `codex` binary against a `wiremock` OAuth server with an isolated temporary `CODEX_HOME`. It asserts: - the exact request sequence is `/oauth/revoke`, `/api/accounts/deviceauth/usercode`, `/api/accounts/deviceauth/token`, then `/oauth/token`; - there is exactly one revoke request and its body contains `old-refresh` with the `refresh_token` hint; - the completed login persists `new-refresh`. Local validation: - `just test -p codex-login` — 130 passed - `just test -p codex-cli` — 280 passed, including the new process-level regression test - `just bazel-lock-check` --- codex-rs/Cargo.lock | 1 + codex-rs/cli/Cargo.toml | 1 + codex-rs/cli/src/login.rs | 40 +++++ codex-rs/cli/tests/login.rs | 110 ++++++++++++++ codex-rs/login/src/auth/manager.rs | 20 +-- codex-rs/login/src/auth/mod.rs | 2 - codex-rs/login/src/auth/revoke.rs | 28 +--- codex-rs/login/src/server.rs | 220 +-------------------------- codex-rs/login/tests/suite/logout.rs | 37 +++++ 9 files changed, 209 insertions(+), 250 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 10bb76842..2e1d8707a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2366,6 +2366,7 @@ dependencies = [ "url", "which 8.0.0", "windows-sys 0.52.0", + "wiremock", ] [[package]] diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 727f36750..8896759f9 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -102,3 +102,4 @@ insta = { workspace = true } predicates = { workspace = true } pretty_assertions = { workspace = true } sqlx = { workspace = true } +wiremock = { workspace = true } diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 64413617e..44f91eb07 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -23,6 +23,7 @@ use codex_utils_cli::CliConfigOverrides; use std::fs::OpenOptions; use std::io::IsTerminal; use std::io::Read; +use std::path::Path; use std::path::PathBuf; use tracing_appender::non_blocking; use tracing_appender::non_blocking::WorkerGuard; @@ -113,11 +114,22 @@ fn print_login_server_start(actual_port: u16, auth_url: &str) { ); } +async fn clear_existing_auth_before_login( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) { + if let Err(err) = logout_with_revoke(codex_home, auth_credentials_store_mode).await { + tracing::warn!("failed to clear existing auth before login: {err}"); + } +} + pub async fn login_with_chatgpt( codex_home: PathBuf, forced_chatgpt_workspace_id: Option>, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> std::io::Result<()> { + clear_existing_auth_before_login(&codex_home, cli_auth_credentials_store_mode).await; + let opts = ServerOptions::new( codex_home, CLIENT_ID.to_string(), @@ -277,6 +289,8 @@ pub async fn run_login_with_device_code( eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } + clear_existing_auth_before_login(&config.codex_home, config.cli_auth_credentials_store_mode) + .await; let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); let mut opts = ServerOptions::new( config.codex_home.to_path_buf(), @@ -315,6 +329,8 @@ pub async fn run_login_with_device_code_fallback_to_browser( eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } + clear_existing_auth_before_login(&config.codex_home, config.cli_auth_credentials_store_mode) + .await; let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); let mut opts = ServerOptions::new( @@ -460,8 +476,32 @@ fn safe_format_key(key: &str) -> String { #[cfg(test)] mod tests { + use codex_config::types::AuthCredentialsStoreMode; + use codex_login::load_auth_dot_json; + use codex_login::login_with_api_key; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + use super::clear_existing_auth_before_login; use super::safe_format_key; + #[tokio::test] + async fn clears_existing_auth_before_login() { + let codex_home = tempdir().expect("create temporary Codex home"); + login_with_api_key( + codex_home.path(), + "sk-existing", + AuthCredentialsStoreMode::File, + ) + .expect("save existing auth"); + + clear_existing_auth_before_login(codex_home.path(), AuthCredentialsStoreMode::File).await; + + let auth = load_auth_dot_json(codex_home.path(), AuthCredentialsStoreMode::File) + .expect("load auth after cleanup"); + assert_eq!(auth, None); + } + #[test] fn formats_long_key() { let key = "sk-proj-1234567890ABCDE"; diff --git a/codex-rs/cli/tests/login.rs b/codex-rs/cli/tests/login.rs index e290d0593..4f37f4141 100644 --- a/codex-rs/cli/tests/login.rs +++ b/codex-rs/cli/tests/login.rs @@ -1,10 +1,19 @@ use std::path::Path; +use anyhow::Context; use anyhow::Result; +use codex_login::CLIENT_ID; +use codex_login::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; use predicates::str::contains; use pretty_assertions::assert_eq; use serde_json::Value; +use serde_json::json; use tempfile::TempDir; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; fn codex_command(codex_home: &Path) -> Result { let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); @@ -64,3 +73,104 @@ fn login_with_access_token_rejects_invalid_jwt() -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn device_login_revokes_existing_auth_before_requesting_new_tokens() -> Result<()> { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/revoke")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/api/accounts/deviceauth/usercode")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "device_auth_id": "device-auth-123", + "user_code": "CODE-12345", + "interval": "0", + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/api/accounts/deviceauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "authorization_code": "authorization-code-123", + "code_challenge": "code-challenge-123", + "code_verifier": "code-verifier-123", + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id_token": "eyJhbGciOiJub25lIn0.e30.c2ln", + "access_token": "new-access", + "refresh_token": "new-refresh", + }))) + .expect(1) + .mount(&server) + .await; + + let codex_home = TempDir::new()?; + write_file_auth_config(codex_home.path())?; + std::fs::write( + codex_home.path().join("auth.json"), + serde_json::to_vec(&json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "id_token": "eyJhbGciOiJub25lIn0.e30.c2ln", + "access_token": "old-access", + "refresh_token": "old-refresh", + "account_id": "old-account", + }, + }))?, + )?; + + let issuer = server.uri(); + let mut cmd = codex_command(codex_home.path())?; + cmd.env( + REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR, + format!("{issuer}/oauth/revoke"), + ) + .env("NO_PROXY", "127.0.0.1,localhost") + .env("no_proxy", "127.0.0.1,localhost") + .env_remove("CODEX_ACCESS_TOKEN") + .env_remove("OPENAI_API_KEY") + .args(["login", "--device-auth", "--experimental_issuer", &issuer]) + .assert() + .success() + .stderr(contains("Successfully logged in")); + + let requests = server + .received_requests() + .await + .context("failed to read mock OAuth requests")?; + let paths: Vec<&str> = requests.iter().map(|request| request.url.path()).collect(); + assert_eq!( + paths, + vec![ + "/oauth/revoke", + "/api/accounts/deviceauth/usercode", + "/api/accounts/deviceauth/token", + "/oauth/token", + ] + ); + assert_eq!( + requests[0] + .body_json::() + .context("revoke request should be JSON")?, + json!({ + "token": "old-refresh", + "token_type_hint": "refresh_token", + "client_id": CLIENT_ID, + }) + ); + + let auth = read_auth_json(codex_home.path())?; + assert_eq!(auth["tokens"]["refresh_token"], "new-refresh"); + Ok(()) +} diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 9961f7ef7..db658e7fd 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -590,15 +590,17 @@ pub async fn logout_with_revoke( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> std::io::Result { - AuthManager::new( - codex_home.to_path_buf(), - /*enable_codex_api_key_env*/ false, - auth_credentials_store_mode, - /*chatgpt_base_url*/ None, - ) - .await - .logout_with_revoke() - .await + let auth_dot_json = match load_auth_dot_json(codex_home, auth_credentials_store_mode) { + Ok(auth_dot_json) => auth_dot_json, + Err(err) => { + tracing::warn!("failed to load stored auth during logout: {err}"); + None + } + }; + if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref()).await { + tracing::warn!("failed to revoke auth tokens during logout: {err}"); + } + logout_all_stores(codex_home, auth_credentials_store_mode) } /// Writes an `auth.json` that contains only the API key. diff --git a/codex-rs/login/src/auth/mod.rs b/codex-rs/login/src/auth/mod.rs index 2e18d530f..d87c7cc6a 100644 --- a/codex-rs/login/src/auth/mod.rs +++ b/codex-rs/login/src/auth/mod.rs @@ -16,5 +16,3 @@ pub use bedrock_api_key::login_with_bedrock_api_key; pub use error::RefreshTokenFailedError; pub use error::RefreshTokenFailedReason; pub use manager::*; -pub(crate) use revoke::revoke_auth_tokens; -pub(crate) use revoke::should_revoke_auth_tokens; diff --git a/codex-rs/login/src/auth/revoke.rs b/codex-rs/login/src/auth/revoke.rs index b6a9fef4b..6695a22ba 100644 --- a/codex-rs/login/src/auth/revoke.rs +++ b/codex-rs/login/src/auth/revoke.rs @@ -1,9 +1,8 @@ -//! Best-effort OAuth token revocation for managed auth cleanup. +//! Best-effort OAuth token revocation used during logout. //! -//! Managed ChatGPT auth stores OAuth tokens locally. Cleanup attempts to revoke -//! the refresh token, falling back to the access token when no refresh token is -//! available, and callers still complete their primary work if the revoke request -//! fails. +//! Managed ChatGPT auth stores OAuth tokens locally. Logout attempts to revoke the +//! refresh token, falling back to the access token when no refresh token is +//! available, and callers still remove local auth if the revoke request fails. use serde::Serialize; use std::time::Duration; @@ -52,7 +51,7 @@ struct RevokeTokenRequest<'a> { client_id: Option<&'static str>, } -pub(crate) async fn revoke_auth_tokens( +pub(super) async fn revoke_auth_tokens( auth_dot_json: Option<&AuthDotJson>, ) -> Result<(), std::io::Error> { let Some((token, kind)) = auth_dot_json.and_then(revocable_token) else { @@ -64,23 +63,6 @@ pub(crate) async fn revoke_auth_tokens( revoke_oauth_token(&client, endpoint.as_str(), token, kind, REVOKE_HTTP_TIMEOUT).await } -pub(crate) fn should_revoke_auth_tokens( - auth_dot_json: Option<&AuthDotJson>, - replacement_auth: &AuthDotJson, -) -> bool { - let Some((token, kind)) = auth_dot_json.and_then(revocable_token) else { - return false; - }; - let Some(replacement_tokens) = managed_chatgpt_tokens(replacement_auth) else { - return true; - }; - - match kind { - RevokeTokenKind::Access => replacement_tokens.access_token != token, - RevokeTokenKind::Refresh => replacement_tokens.refresh_token != token, - } -} - fn revocable_token(auth_dot_json: &AuthDotJson) -> Option<(&str, RevokeTokenKind)> { let tokens = managed_chatgpt_tokens(auth_dot_json)?; if !tokens.refresh_token.is_empty() { diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index a91017bea..b94f2ac54 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -25,10 +25,7 @@ use std::thread; use std::time::Duration; use crate::auth::AuthDotJson; -use crate::auth::load_auth_dot_json; -use crate::auth::revoke_auth_tokens; use crate::auth::save_auth; -use crate::auth::should_revoke_auth_tokens; use crate::default_client::originator; use crate::pkce::PkceCodes; use crate::pkce::generate_pkce; @@ -784,8 +781,7 @@ pub(crate) async fn exchange_code_for_tokens( }) } -/// Persists exchanged credentials using the configured local auth store, then -/// best-effort revokes any superseded managed ChatGPT tokens. +/// Persists exchanged credentials using the configured local auth store. pub(crate) async fn persist_tokens_async( codex_home: &Path, api_key: Option, @@ -796,14 +792,7 @@ pub(crate) async fn persist_tokens_async( ) -> io::Result<()> { // Reuse existing synchronous logic but run it off the async runtime. let codex_home = codex_home.to_path_buf(); - let (previous_auth, auth) = tokio::task::spawn_blocking(move || { - let previous_auth = match load_auth_dot_json(&codex_home, auth_credentials_store_mode) { - Ok(auth) => auth, - Err(err) => { - warn!("failed to load previous auth before saving new login: {err}"); - None - } - }; + tokio::task::spawn_blocking(move || { let mut tokens = TokenData { id_token: parse_chatgpt_jwt_claims(&id_token).map_err(io::Error::other)?, access_token, @@ -825,19 +814,10 @@ pub(crate) async fn persist_tokens_async( personal_access_token: None, bedrock_api_key: None, }; - save_auth(&codex_home, &auth, auth_credentials_store_mode)?; - Ok::<_, io::Error>((previous_auth, auth)) + save_auth(&codex_home, &auth, auth_credentials_store_mode) }) .await - .map_err(|e| io::Error::other(format!("persist task failed: {e}")))??; - - if should_revoke_auth_tokens(previous_auth.as_ref(), &auth) - && let Err(err) = revoke_auth_tokens(previous_auth.as_ref()).await - { - warn!("failed to revoke superseded auth tokens after login: {err}"); - } - - Ok(()) + .map_err(|e| io::Error::other(format!("persist task failed: {e}")))? } fn compose_success_url( @@ -1171,28 +1151,6 @@ pub(crate) async fn obtain_api_key( } #[cfg(test)] mod tests { - use std::ffi::OsString; - - use anyhow::Context; - use base64::Engine; - use codex_app_server_protocol::AuthMode; - use codex_config::types::AuthCredentialsStoreMode; - use serde_json::Value; - use serde_json::json; - use tempfile::tempdir; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::method; - use wiremock::matchers::path; - - use crate::auth::AuthDotJson; - use crate::auth::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; - use crate::auth::load_auth_dot_json; - use crate::auth::save_auth; - use crate::token_data::TokenData; - use crate::token_data::parse_chatgpt_jwt_claims; - use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; use super::DEFAULT_ISSUER; @@ -1201,181 +1159,11 @@ mod tests { use super::html_escape; use super::is_missing_codex_entitlement_error; use super::parse_token_endpoint_error; - use super::persist_tokens_async; use super::redact_sensitive_query_value; use super::redact_sensitive_url_parts; use super::render_login_error_page; use super::sanitize_url_for_logging; - #[serial_test::serial(logout_revoke)] - #[tokio::test] - async fn persist_tokens_async_revokes_previous_auth_without_failing_login() -> anyhow::Result<()> - { - skip_if_no_network!(Ok(())); - - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/oauth/revoke")) - .respond_with(ResponseTemplate::new(500).set_body_json(json!({ - "error": { - "message": "revoke failed" - } - }))) - .expect(1) - .mount(&server) - .await; - let _env_guard = EnvGuard::set( - REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR, - format!("{}/oauth/revoke", server.uri()), - ); - - let codex_home = tempdir()?; - save_auth( - codex_home.path(), - &chatgpt_auth("old-access", "old-refresh", "old-account"), - AuthCredentialsStoreMode::File, - )?; - - persist_tokens_async( - codex_home.path(), - /*api_key*/ None, - jwt_for_account("new-account"), - "new-access".to_string(), - "new-refresh".to_string(), - AuthCredentialsStoreMode::File, - ) - .await?; - - let auth = load_auth_dot_json(codex_home.path(), AuthCredentialsStoreMode::File)? - .context("auth.json should exist after login")?; - assert_eq!( - auth.tokens.context("new tokens should be persisted")?, - TokenData { - id_token: parse_chatgpt_jwt_claims(&jwt_for_account("new-account")) - .expect("new JWT should parse"), - access_token: "new-access".to_string(), - refresh_token: "new-refresh".to_string(), - account_id: Some("new-account".to_string()), - } - ); - - let requests = server - .received_requests() - .await - .context("failed to fetch revoke requests")?; - assert_eq!(requests.len(), 1); - assert_eq!( - requests[0] - .body_json::() - .context("revoke request should be JSON")?, - json!({ - "token": "old-refresh", - "token_type_hint": "refresh_token", - "client_id": crate::auth::CLIENT_ID, - }) - ); - server.verify().await; - Ok(()) - } - - #[serial_test::serial(logout_revoke)] - #[tokio::test] - async fn persist_tokens_async_does_not_revoke_reused_refresh_token() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = MockServer::start().await; - let _env_guard = EnvGuard::set( - REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR, - format!("{}/oauth/revoke", server.uri()), - ); - - let codex_home = tempdir()?; - save_auth( - codex_home.path(), - &chatgpt_auth("old-access", "shared-refresh", "old-account"), - AuthCredentialsStoreMode::File, - )?; - - persist_tokens_async( - codex_home.path(), - /*api_key*/ None, - jwt_for_account("new-account"), - "new-access".to_string(), - "shared-refresh".to_string(), - AuthCredentialsStoreMode::File, - ) - .await?; - - let requests = server - .received_requests() - .await - .context("failed to fetch revoke requests")?; - assert_eq!(requests.len(), 0); - Ok(()) - } - - fn chatgpt_auth(access_token: &str, refresh_token: &str, account_id: &str) -> AuthDotJson { - AuthDotJson { - auth_mode: Some(AuthMode::Chatgpt), - openai_api_key: None, - tokens: Some(TokenData { - id_token: parse_chatgpt_jwt_claims(&jwt_for_account(account_id)) - .expect("test JWT should parse"), - access_token: access_token.to_string(), - refresh_token: refresh_token.to_string(), - account_id: Some(account_id.to_string()), - }), - last_refresh: None, - agent_identity: None, - personal_access_token: None, - bedrock_api_key: None, - } - } - - fn jwt_for_account(account_id: &str) -> String { - let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); - let header_b64 = encode(br#"{"alg":"none","typ":"JWT"}"#); - let payload_b64 = encode( - serde_json::to_string(&json!({ - "https://api.openai.com/auth": { - "chatgpt_account_id": account_id, - } - })) - .expect("payload should serialize") - .as_bytes(), - ); - let signature_b64 = encode(b"sig"); - format!("{header_b64}.{payload_b64}.{signature_b64}") - } - - struct EnvGuard { - key: &'static str, - original: Option, - } - - impl EnvGuard { - fn set(key: &'static str, value: String) -> Self { - let original = std::env::var_os(key); - // SAFETY: this test executes serially with other revoke tests. - unsafe { - std::env::set_var(key, &value); - } - Self { key, original } - } - } - - impl Drop for EnvGuard { - fn drop(&mut self) { - // SAFETY: the guard restores the original environment before other revoke tests run. - unsafe { - match &self.original { - Some(value) => std::env::set_var(self.key, value), - None => std::env::remove_var(self.key), - } - } - } - } - #[test] fn parse_token_endpoint_error_prefers_error_description() { let detail = parse_token_endpoint_error( diff --git a/codex-rs/login/tests/suite/logout.rs b/codex-rs/login/tests/suite/logout.rs index 5d7580397..30f0dd660 100644 --- a/codex-rs/login/tests/suite/logout.rs +++ b/codex-rs/login/tests/suite/logout.rs @@ -6,6 +6,7 @@ use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthDotJson; use codex_login::AuthManager; use codex_login::CLIENT_ID; +use codex_login::CODEX_ACCESS_TOKEN_ENV_VAR; use codex_login::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_login::logout_with_revoke; use codex_login::save_auth; @@ -76,6 +77,42 @@ async fn logout_with_revoke_revokes_refresh_token_then_removes_auth() -> Result< Ok(()) } +#[serial_test::serial(logout_revoke)] +#[tokio::test] +async fn logout_with_revoke_uses_stored_auth_when_access_token_env_is_set() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/revoke")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + let _revoke_env_guard = EnvGuard::set( + REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR, + format!("{}/oauth/revoke", server.uri()), + ); + let _access_token_env_guard = EnvGuard::set( + CODEX_ACCESS_TOKEN_ENV_VAR, + "at-environment-token".to_string(), + ); + + let codex_home = TempDir::new()?; + save_auth( + codex_home.path(), + &chatgpt_auth(), + AuthCredentialsStoreMode::File, + )?; + + let removed = logout_with_revoke(codex_home.path(), AuthCredentialsStoreMode::File).await?; + + assert!(removed); + assert!(!codex_home.path().join("auth.json").exists()); + server.verify().await; + Ok(()) +} + #[serial_test::serial(logout_revoke)] #[tokio::test] async fn logout_with_revoke_removes_auth_when_revoke_fails() -> Result<()> {