mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
[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`
This commit is contained in:
1 parent
b724f5966e
commit
d1aaf789ad
9 files changed
+209
-250
No files matched your search
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -76,6 +77,42 @@ async fn logout_with_revoke_revokes_refresh_token_then_removes_auth() -> Result<
|
||||
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<()> {
|
||||
|
||||
Reference in new issue
Block a user