mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
## 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`
201 lines
5.7 KiB
Rust
201 lines
5.7 KiB
Rust
//! 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((token, kind)) = auth_dot_json.and_then(revocable_token) else {
|
|
return Ok(());
|
|
};
|
|
|
|
let client = create_client();
|
|
let endpoint = revoke_token_endpoint();
|
|
revoke_oauth_token(&client, endpoint.as_str(), token, kind, REVOKE_HTTP_TIMEOUT).await
|
|
}
|
|
|
|
fn revocable_token(auth_dot_json: &AuthDotJson) -> Option<(&str, RevokeTokenKind)> {
|
|
let tokens = managed_chatgpt_tokens(auth_dot_json)?;
|
|
if !tokens.refresh_token.is_empty() {
|
|
Some((tokens.refresh_token.as_str(), RevokeTokenKind::Refresh))
|
|
} else if !tokens.access_token.is_empty() {
|
|
Some((tokens.access_token.as_str(), RevokeTokenKind::Access))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
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());
|
|
}
|
|
}
|