diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 0f3b8bf52268..3a3a8e6d4138 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1697,12 +1697,15 @@ impl CodexMessageProcessor { } } - if let Err(err) = self.auth_manager.logout() { - return Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("logout failed: {err}"), - data: None, - }); + match self.auth_manager.logout_with_revoke().await { + Ok(_) => {} + Err(err) => { + return Err(JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("logout failed: {err}"), + data: None, + }); + } } // Reflect the current auth method after logout (likely None). diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index bd17a546a1ff..fd0dfee3afca 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -14,7 +14,7 @@ use codex_login::CLIENT_ID; use codex_login::CodexAuth; use codex_login::ServerOptions; use codex_login::login_with_api_key; -use codex_login::logout; +use codex_login::logout_with_revoke; use codex_login::run_device_code_login; use codex_login::run_login_server; use codex_protocol::config_types::ForcedLoginMethod; @@ -347,7 +347,7 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; - match logout(&config.codex_home, config.cli_auth_credentials_store_mode) { + match logout_with_revoke(&config.codex_home, config.cli_auth_credentials_store_mode).await { Ok(true) => { eprintln!("Successfully logged out"); std::process::exit(0); diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 3cb97f58b00b..c67d73fedff3 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -21,6 +21,7 @@ use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ModelProviderAuthInfo; use super::external_bearer::BearerTokenRefresher; +use super::revoke::revoke_auth_tokens; pub use crate::auth::storage::AgentIdentityAuthRecord; pub use crate::auth::storage::AuthDotJson; use crate::auth::storage::AuthStorageBackend; @@ -86,7 +87,9 @@ 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 REFRESH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; +pub(super) const REVOKE_TOKEN_URL: &str = "https://auth.openai.com/oauth/revoke"; pub const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REFRESH_TOKEN_URL_OVERRIDE"; +pub const REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REVOKE_TOKEN_URL_OVERRIDE"; #[derive(Debug, Error)] pub enum RefreshTokenError { @@ -483,6 +486,19 @@ pub fn logout( storage.delete() } +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, + ) + .logout_with_revoke() + .await +} + /// Writes an `auth.json` that contains only the API key. pub fn login_with_api_key( codex_home: &Path, @@ -1637,6 +1653,19 @@ impl AuthManager { Ok(removed) } + pub async fn logout_with_revoke(&self) -> std::io::Result { + let auth_dot_json = self + .auth_cached() + .and_then(|auth| auth.get_current_auth_json()); + if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref()).await { + tracing::warn!("failed to revoke auth tokens during logout: {err}"); + } + let result = logout_all_stores(&self.codex_home, self.auth_credentials_store_mode)?; + // Always reload to clear any cached auth (even if file absent). + self.reload(); + Ok(result) + } + pub fn get_api_auth_mode(&self) -> Option { if self.has_external_api_key_auth() { return Some(ApiAuthMode::ApiKey); diff --git a/codex-rs/login/src/auth/mod.rs b/codex-rs/login/src/auth/mod.rs index 256cf16a8c2d..b927f9a77520 100644 --- a/codex-rs/login/src/auth/mod.rs +++ b/codex-rs/login/src/auth/mod.rs @@ -5,6 +5,7 @@ mod util; mod external_bearer; mod manager; +mod revoke; pub use error::RefreshTokenFailedError; pub use error::RefreshTokenFailedReason; diff --git a/codex-rs/login/src/auth/revoke.rs b/codex-rs/login/src/auth/revoke.rs new file mode 100644 index 000000000000..71164523a9da --- /dev/null +++ b/codex-rs/login/src/auth/revoke.rs @@ -0,0 +1,209 @@ +//! Best-effort OAuth token revocation used during logout. +//! +//! 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; + +use codex_app_server_protocol::AuthMode as ApiAuthMode; +use codex_client::CodexHttpClient; + +use super::manager::CLIENT_ID; +use super::manager::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use super::manager::REVOKE_TOKEN_URL; +use super::manager::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; +use super::storage::AuthDotJson; +use super::util::try_parse_error_message; +use crate::default_client::create_client; +use crate::token_data::TokenData; + +const REVOKE_HTTP_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RevokeTokenKind { + Access, + Refresh, +} + +impl RevokeTokenKind { + fn as_str(self) -> &'static str { + match self { + Self::Access => "access_token", + Self::Refresh => "refresh_token", + } + } + + fn client_id(self) -> Option<&'static str> { + match self { + Self::Access => None, + Self::Refresh => Some(CLIENT_ID), + } + } +} + +#[derive(Serialize)] +struct RevokeTokenRequest<'a> { + token: &'a str, + token_type_hint: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + client_id: Option<&'static str>, +} + +pub(super) async fn revoke_auth_tokens( + auth_dot_json: Option<&AuthDotJson>, +) -> Result<(), std::io::Error> { + let Some(tokens) = auth_dot_json.and_then(managed_chatgpt_tokens) else { + return Ok(()); + }; + + let client = create_client(); + let endpoint = revoke_token_endpoint(); + if !tokens.refresh_token.is_empty() { + revoke_oauth_token( + &client, + endpoint.as_str(), + tokens.refresh_token.as_str(), + RevokeTokenKind::Refresh, + REVOKE_HTTP_TIMEOUT, + ) + .await + } else if !tokens.access_token.is_empty() { + revoke_oauth_token( + &client, + endpoint.as_str(), + tokens.access_token.as_str(), + RevokeTokenKind::Access, + REVOKE_HTTP_TIMEOUT, + ) + .await + } else { + Ok(()) + } +} + +fn managed_chatgpt_tokens(auth_dot_json: &AuthDotJson) -> Option<&TokenData> { + if resolved_auth_mode(auth_dot_json) == ApiAuthMode::Chatgpt { + auth_dot_json.tokens.as_ref() + } else { + None + } +} + +fn resolved_auth_mode(auth_dot_json: &AuthDotJson) -> ApiAuthMode { + if let Some(mode) = auth_dot_json.auth_mode { + return mode; + } + if auth_dot_json.openai_api_key.is_some() { + return ApiAuthMode::ApiKey; + } + ApiAuthMode::Chatgpt +} + +async fn revoke_oauth_token( + client: &CodexHttpClient, + endpoint: &str, + token: &str, + kind: RevokeTokenKind, + timeout: Duration, +) -> Result<(), std::io::Error> { + let request = RevokeTokenRequest { + token, + token_type_hint: kind.as_str(), + client_id: kind.client_id(), + }; + + let response = client + .post(endpoint) + .header("Content-Type", "application/json") + .timeout(timeout) + .json(&request) + .send() + .await + .map_err(std::io::Error::other)?; + + let status = response.status(); + if status.is_success() { + return Ok(()); + } + + let body = response.text().await.unwrap_or_default(); + let message = try_parse_error_message(&body); + Err(std::io::Error::other(format!( + "failed to revoke {}: {}: {}", + kind.as_str(), + status, + message + ))) +} + +fn revoke_token_endpoint() -> String { + if let Ok(endpoint) = std::env::var(REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR) { + return endpoint; + } + + if let Ok(refresh_endpoint) = std::env::var(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR) + && let Some(endpoint) = derive_revoke_token_endpoint(&refresh_endpoint) + { + return endpoint; + } + + REVOKE_TOKEN_URL.to_string() +} + +fn derive_revoke_token_endpoint(refresh_endpoint: &str) -> Option { + let mut url = url::Url::parse(refresh_endpoint).ok()?; + url.set_path("/oauth/revoke"); + url.set_query(None); + Some(url.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use core_test_support::skip_if_no_network; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + #[test] + fn derives_revoke_url_from_refresh_token_override() { + assert_eq!( + derive_revoke_token_endpoint("http://127.0.0.1:1234/oauth/token?unified=true"), + Some("http://127.0.0.1:1234/oauth/revoke".to_string()) + ); + } + + #[tokio::test] + async fn revoke_request_times_out() { + skip_if_no_network!(); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/revoke")) + .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(60))) + .mount(&server) + .await; + + let client = CodexHttpClient::new(reqwest::Client::new()); + let endpoint = format!("{}/oauth/revoke", server.uri()); + let error = revoke_oauth_token( + &client, + endpoint.as_str(), + "refresh-token", + RevokeTokenKind::Refresh, + Duration::from_millis(20), + ) + .await + .expect_err("stalled revoke request should time out"); + + let reqwest_error = error + .get_ref() + .and_then(|error| error.downcast_ref::()) + .expect("timeout error should preserve reqwest error"); + assert!(reqwest_error.is_timeout()); + } +} diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 4880e431061f..c655ec2ac982 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -35,6 +35,7 @@ pub use auth::ExternalAuthRefreshReason; pub use auth::ExternalAuthTokens; pub use auth::OPENAI_API_KEY_ENV_VAR; pub use auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +pub use auth::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; pub use auth::RefreshTokenError; pub use auth::UnauthorizedRecovery; pub use auth::default_client; @@ -42,6 +43,7 @@ pub use auth::enforce_login_restrictions; pub use auth::load_auth_dot_json; pub use auth::login_with_api_key; pub use auth::logout; +pub use auth::logout_with_revoke; pub use auth::read_openai_api_key_from_env; pub use auth::save_auth; pub use auth_env_telemetry::AuthEnvTelemetry; diff --git a/codex-rs/login/tests/suite/logout.rs b/codex-rs/login/tests/suite/logout.rs new file mode 100644 index 000000000000..59de9dabda92 --- /dev/null +++ b/codex-rs/login/tests/suite/logout.rs @@ -0,0 +1,233 @@ +use anyhow::Context; +use anyhow::Result; +use base64::Engine; +use codex_app_server_protocol::AuthMode; +use codex_config::types::AuthCredentialsStoreMode; +use codex_login::AuthDotJson; +use codex_login::AuthManager; +use codex_login::CLIENT_ID; +use codex_login::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; +use codex_login::logout_with_revoke; +use codex_login::save_auth; +use codex_login::token_data::IdTokenInfo; +use codex_login::token_data::TokenData; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use std::ffi::OsString; +use tempfile::TempDir; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +const ACCESS_TOKEN: &str = "access-token"; +const REFRESH_TOKEN: &str = "refresh-token"; + +#[serial_test::serial(logout_revoke)] +#[tokio::test] +async fn logout_with_revoke_revokes_refresh_token_then_removes_auth() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/revoke")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "message": "success" + }))) + .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::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()); + + 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": REFRESH_TOKEN, + "token_type_hint": "refresh_token", + "client_id": CLIENT_ID, + }) + ); + server.verify().await; + Ok(()) +} + +#[serial_test::serial(logout_revoke)] +#[tokio::test] +async fn logout_with_revoke_removes_auth_when_revoke_fails() -> 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::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 auth_manager_logout_with_revoke_uses_cached_auth() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/revoke")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "message": "success" + }))) + .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::new()?; + save_auth( + codex_home.path(), + &chatgpt_auth_with_refresh_token(REFRESH_TOKEN), + AuthCredentialsStoreMode::File, + )?; + let manager = AuthManager::new( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + ); + save_auth( + codex_home.path(), + &chatgpt_auth_with_refresh_token("newer-disk-refresh-token"), + AuthCredentialsStoreMode::File, + )?; + + let removed = manager.logout_with_revoke().await?; + + assert!(removed); + assert!(manager.auth_cached().is_none()); + assert!(!codex_home.path().join("auth.json").exists()); + + 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": REFRESH_TOKEN, + "token_type_hint": "refresh_token", + "client_id": CLIENT_ID, + }) + ); + server.verify().await; + Ok(()) +} + +fn chatgpt_auth() -> AuthDotJson { + chatgpt_auth_with_refresh_token(REFRESH_TOKEN) +} + +fn chatgpt_auth_with_refresh_token(refresh_token: &str) -> AuthDotJson { + AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: IdTokenInfo { + raw_jwt: minimal_jwt(), + ..Default::default() + }, + 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, + } +} + +fn minimal_jwt() -> String { + let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b); + let header_b64 = b64(br#"{"alg":"none"}"#); + let payload_b64 = b64(br#"{"sub":"user-123"}"#); + let signature_b64 = b64(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: these tests execute serially, so updating the process environment is safe. + 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 value before other tests run. + unsafe { + match &self.original { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } +} diff --git a/codex-rs/login/tests/suite/mod.rs b/codex-rs/login/tests/suite/mod.rs index 3d1eddd1a1ab..3c3bb24d62dc 100644 --- a/codex-rs/login/tests/suite/mod.rs +++ b/codex-rs/login/tests/suite/mod.rs @@ -2,3 +2,4 @@ mod auth_refresh; mod device_code_login; mod login_server_e2e; +mod logout; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ab20b2214cd5..4efb55497d86 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -4559,6 +4559,18 @@ impl App { AppEvent::Exit(mode) => { return Ok(self.handle_exit_mode(app_server, mode).await); } + AppEvent::Logout => match app_server.logout_account().await { + Ok(()) => { + return Ok(self + .handle_exit_mode(app_server, ExitMode::ShutdownFirst) + .await); + } + Err(err) => { + tracing::error!("failed to logout: {err}"); + self.chat_widget + .add_error_message(format!("Logout failed: {err}")); + } + }, AppEvent::FatalExitRequest(message) => { return Ok(AppRunControl::Exit(ExitReason::Fatal(message))); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 934baac2317e..e6f8898a5f27 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -139,6 +139,9 @@ pub(crate) enum AppEvent { /// background tasks, rollout flush, or child process cleanup). Exit(ExitMode), + /// Request app-server account logout, then exit after it succeeds. + Logout, + /// Request to exit the application due to a fatal error. #[allow(dead_code)] FatalExitRequest(String), diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 5e11a5334001..677614907ce5 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -18,6 +18,7 @@ use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::GetAccountResponse; use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::LogoutAccountResponse; use codex_app_server_protocol::MemoryResetResponse; use codex_app_server_protocol::Model as ApiModel; use codex_app_server_protocol::ModelListParams; @@ -553,6 +554,19 @@ impl AppServerSession { Ok(()) } + pub(crate) async fn logout_account(&mut self) -> Result<()> { + let request_id = self.next_request_id(); + let _: LogoutAccountResponse = self + .client + .request_typed(ClientRequest::LogoutAccount { + request_id, + params: None, + }) + .await + .wrap_err("account/logout failed in TUI")?; + Ok(()) + } + pub(crate) async fn thread_unsubscribe(&mut self, thread_id: ThreadId) -> Result<()> { let request_id = self.next_request_id(); let _: ThreadUnsubscribeResponse = self diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 2c5c83b380f3..c2cec8626c7e 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -235,13 +235,7 @@ impl ChatWidget { self.request_quit_without_confirmation(); } SlashCommand::Logout => { - if let Err(e) = codex_login::logout( - &self.config.codex_home, - self.config.cli_auth_credentials_store_mode, - ) { - tracing::error!("failed to logout: {e}"); - } - self.request_quit_without_confirmation(); + self.app_event_tx.send(AppEvent::Logout); } // SlashCommand::Undo => { // self.app_event_tx.send(AppEvent::CodexOp(Op::Undo)); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index c7caacc2eba0..e9276582594c 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -247,6 +247,15 @@ async fn slash_quit_requests_exit() { assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); } +#[tokio::test] +async fn slash_logout_requests_app_server_logout() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Logout); + + assert_matches!(rx.try_recv(), Ok(AppEvent::Logout)); +} + #[tokio::test] async fn slash_copy_state_tracks_turn_complete_final_reply() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;