mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[codex] Revoke ChatGPT tokens on logout (#17825)
## Summary This changes Codex logout so managed ChatGPT auth is revoked against AuthAPI before local auth state is removed. CLI logout, TUI `/logout`, and the app-server account logout path now use the token-revoking logout flow instead of only deleting `auth.json` / credential store state. ## Root Cause Logout previously cleared only local auth storage. That removed Codex's local credentials but did not ask the backend to invalidate the refresh/access token state associated with a managed ChatGPT login. ## Behavior For managed ChatGPT auth, logout sends the stored refresh token to `https://auth.openai.com/oauth/revoke` with `token_type_hint: refresh_token` and the Codex OAuth client id, then deletes all local auth stores after revocation succeeds. If only an access token is available, it falls back to revoking that access token. API key auth and externally supplied `chatgptAuthTokens` are still only cleared locally because Codex does not own a refresh token for those modes. Revocation failures are fail-closed: if Codex cannot load stored auth or the backend revoke call fails, logout returns an error and leaves local auth in place so the user can retry instead of silently clearing local state while backend tokens remain valid. ## Validation ran local version of `codex-cli` with staging overrides/harness for auth ran `codex login` then `codex logout`: saw auth.json clear and backend revocation endpoints were called ``` POST /oauth/revoke status: 200 revoking access token should clear auth session clearing auth session due to token revocation successfully revoked session and access token CANONICAL-API-LINE Response: status='200' method='POST' path='/oauth/revoke ``` --------- Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
fe7c959e90
commit
22f7ef1cb7
@@ -1702,12 +1702,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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<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,
|
||||
@@ -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);
|
||||
|
||||
@@ -5,6 +5,7 @@ mod util;
|
||||
|
||||
mod external_bearer;
|
||||
mod manager;
|
||||
mod revoke;
|
||||
|
||||
pub use error::RefreshTokenFailedError;
|
||||
pub use error::RefreshTokenFailedReason;
|
||||
|
||||
@@ -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<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());
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,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;
|
||||
@@ -39,6 +40,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;
|
||||
|
||||
@@ -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::<Value>()
|
||||
.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::<Value>()
|
||||
.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<OsString>,
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,4 @@
|
||||
mod auth_refresh;
|
||||
mod device_code_login;
|
||||
mod login_server_e2e;
|
||||
mod logout;
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user