Files
codex/codex-rs/login/tests/suite/logout.rs
T
cooper-oai d1aaf789ad [login] revoke existing auth before starting login (#27674)
## 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=<temporary-home> \
     CODEX_REVOKE_TOKEN_URL_OVERRIDE=<mock-issuer>/oauth/revoke \
<compiled-codex> login --device-auth --experimental_issuer <mock-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`
2026-06-12 12:38:30 -07:00

275 lines
7.8 KiB
Rust

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::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;
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_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<()> {
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,
/*chatgpt_base_url*/ None,
)
.await;
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,
personal_access_token: None,
bedrock_api_key: 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),
}
}
}
}