mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00: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:
1 parent
fe7c959e90
commit
22f7ef1cb7
13 files changed
+525
-15
No files matched your search
@@ -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;
|
||||
Reference in new issue
Block a user