Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions codex-rs/app-server/src/codex_message_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 2 additions & 2 deletions codex-rs/cli/src/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
29 changes: 29 additions & 0 deletions codex-rs/login/src/auth/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<bool> {
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,
Expand Down Expand Up @@ -1637,6 +1653,19 @@ impl AuthManager {
Ok(removed)
}

pub async fn logout_with_revoke(&self) -> std::io::Result<bool> {
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<ApiAuthMode> {
if self.has_external_api_key_auth() {
return Some(ApiAuthMode::ApiKey);
Expand Down
1 change: 1 addition & 0 deletions codex-rs/login/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod util;

mod external_bearer;
mod manager;
mod revoke;

pub use error::RefreshTokenFailedError;
pub use error::RefreshTokenFailedReason;
Expand Down
209 changes: 209 additions & 0 deletions codex-rs/login/src/auth/revoke.rs
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Add header comment explaining what this module does.

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)?;
Comment thread
sashank-oai marked this conversation as resolved.

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<String> {
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::<reqwest::Error>())
.expect("timeout error should preserve reqwest error");
assert!(reqwest_error.is_timeout());
}
}
2 changes: 2 additions & 0 deletions codex-rs/login/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,15 @@ 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;
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;
Expand Down
Loading
Loading