Files
codex/codex-rs/cli/tests/login.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

177 lines
5.3 KiB
Rust

use std::path::Path;
use anyhow::Context;
use anyhow::Result;
use codex_login::CLIENT_ID;
use codex_login::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR;
use predicates::str::contains;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use tempfile::TempDir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
cmd.env("CODEX_HOME", codex_home);
Ok(cmd)
}
fn write_file_auth_config(codex_home: &Path) -> Result<()> {
std::fs::write(
codex_home.join("config.toml"),
"cli_auth_credentials_store = \"file\"\n",
)?;
Ok(())
}
fn read_auth_json(codex_home: &Path) -> Result<Value> {
let auth_json = std::fs::read_to_string(codex_home.join("auth.json"))?;
Ok(serde_json::from_str(&auth_json)?)
}
#[test]
fn login_with_api_key_reads_stdin_and_writes_auth_json() -> Result<()> {
let codex_home = TempDir::new()?;
write_file_auth_config(codex_home.path())?;
let mut cmd = codex_command(codex_home.path())?;
cmd.args([
"-c",
"forced_login_method=\"api\"",
"login",
"--with-api-key",
])
.write_stdin("sk-test\n")
.assert()
.success()
.stderr(contains("Successfully logged in"));
let auth = read_auth_json(codex_home.path())?;
assert_eq!(auth["OPENAI_API_KEY"], "sk-test");
assert!(auth.get("tokens").is_none());
assert!(auth.get("agent_identity").is_none());
Ok(())
}
#[test]
fn login_with_access_token_rejects_invalid_jwt() -> Result<()> {
let codex_home = TempDir::new()?;
write_file_auth_config(codex_home.path())?;
let mut cmd = codex_command(codex_home.path())?;
cmd.args(["login", "--with-access-token"])
.write_stdin("not-a-jwt\n")
.assert()
.failure()
.stderr(contains("Error logging in with access token"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn device_login_revokes_existing_auth_before_requesting_new_tokens() -> Result<()> {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/revoke"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/accounts/deviceauth/usercode"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"device_auth_id": "device-auth-123",
"user_code": "CODE-12345",
"interval": "0",
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/accounts/deviceauth/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"authorization_code": "authorization-code-123",
"code_challenge": "code-challenge-123",
"code_verifier": "code-verifier-123",
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id_token": "eyJhbGciOiJub25lIn0.e30.c2ln",
"access_token": "new-access",
"refresh_token": "new-refresh",
})))
.expect(1)
.mount(&server)
.await;
let codex_home = TempDir::new()?;
write_file_auth_config(codex_home.path())?;
std::fs::write(
codex_home.path().join("auth.json"),
serde_json::to_vec(&json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"id_token": "eyJhbGciOiJub25lIn0.e30.c2ln",
"access_token": "old-access",
"refresh_token": "old-refresh",
"account_id": "old-account",
},
}))?,
)?;
let issuer = server.uri();
let mut cmd = codex_command(codex_home.path())?;
cmd.env(
REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR,
format!("{issuer}/oauth/revoke"),
)
.env("NO_PROXY", "127.0.0.1,localhost")
.env("no_proxy", "127.0.0.1,localhost")
.env_remove("CODEX_ACCESS_TOKEN")
.env_remove("OPENAI_API_KEY")
.args(["login", "--device-auth", "--experimental_issuer", &issuer])
.assert()
.success()
.stderr(contains("Successfully logged in"));
let requests = server
.received_requests()
.await
.context("failed to read mock OAuth requests")?;
let paths: Vec<&str> = requests.iter().map(|request| request.url.path()).collect();
assert_eq!(
paths,
vec![
"/oauth/revoke",
"/api/accounts/deviceauth/usercode",
"/api/accounts/deviceauth/token",
"/oauth/token",
]
);
assert_eq!(
requests[0]
.body_json::<Value>()
.context("revoke request should be JSON")?,
json!({
"token": "old-refresh",
"token_type_hint": "refresh_token",
"client_id": CLIENT_ID,
})
);
let auth = read_auth_json(codex_home.path())?;
assert_eq!(auth["tokens"]["refresh_token"], "new-refresh");
Ok(())
}