mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08: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:
committed by
GitHub
Unverified
parent
b724f5966e
commit
d1aaf789ad
Generated
+1
@@ -2366,6 +2366,7 @@ dependencies = [
|
||||
"url",
|
||||
"which 8.0.0",
|
||||
"windows-sys 0.52.0",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -102,3 +102,4 @@ insta = { workspace = true }
|
||||
predicates = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
wiremock = { workspace = true }
|
||||
|
||||
@@ -23,6 +23,7 @@ use codex_utils_cli::CliConfigOverrides;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::IsTerminal;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tracing_appender::non_blocking;
|
||||
use tracing_appender::non_blocking::WorkerGuard;
|
||||
@@ -113,11 +114,22 @@ fn print_login_server_start(actual_port: u16, auth_url: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
async fn clear_existing_auth_before_login(
|
||||
codex_home: &Path,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
) {
|
||||
if let Err(err) = logout_with_revoke(codex_home, auth_credentials_store_mode).await {
|
||||
tracing::warn!("failed to clear existing auth before login: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn login_with_chatgpt(
|
||||
codex_home: PathBuf,
|
||||
forced_chatgpt_workspace_id: Option<Vec<String>>,
|
||||
cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
) -> std::io::Result<()> {
|
||||
clear_existing_auth_before_login(&codex_home, cli_auth_credentials_store_mode).await;
|
||||
|
||||
let opts = ServerOptions::new(
|
||||
codex_home,
|
||||
CLIENT_ID.to_string(),
|
||||
@@ -277,6 +289,8 @@ pub async fn run_login_with_device_code(
|
||||
eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
clear_existing_auth_before_login(&config.codex_home, config.cli_auth_credentials_store_mode)
|
||||
.await;
|
||||
let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone();
|
||||
let mut opts = ServerOptions::new(
|
||||
config.codex_home.to_path_buf(),
|
||||
@@ -315,6 +329,8 @@ pub async fn run_login_with_device_code_fallback_to_browser(
|
||||
eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
clear_existing_auth_before_login(&config.codex_home, config.cli_auth_credentials_store_mode)
|
||||
.await;
|
||||
|
||||
let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone();
|
||||
let mut opts = ServerOptions::new(
|
||||
@@ -460,8 +476,32 @@ fn safe_format_key(key: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_login::load_auth_dot_json;
|
||||
use codex_login::login_with_api_key;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::clear_existing_auth_before_login;
|
||||
use super::safe_format_key;
|
||||
|
||||
#[tokio::test]
|
||||
async fn clears_existing_auth_before_login() {
|
||||
let codex_home = tempdir().expect("create temporary Codex home");
|
||||
login_with_api_key(
|
||||
codex_home.path(),
|
||||
"sk-existing",
|
||||
AuthCredentialsStoreMode::File,
|
||||
)
|
||||
.expect("save existing auth");
|
||||
|
||||
clear_existing_auth_before_login(codex_home.path(), AuthCredentialsStoreMode::File).await;
|
||||
|
||||
let auth = load_auth_dot_json(codex_home.path(), AuthCredentialsStoreMode::File)
|
||||
.expect("load auth after cleanup");
|
||||
assert_eq!(auth, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_long_key() {
|
||||
let key = "sk-proj-1234567890ABCDE";
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
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")?);
|
||||
@@ -64,3 +73,104 @@ fn login_with_access_token_rejects_invalid_jwt() -> Result<()> {
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -590,15 +590,17 @@ pub async fn logout_with_revoke(
|
||||
codex_home: &Path,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
) -> std::io::Result<bool> {
|
||||
AuthManager::new(
|
||||
codex_home.to_path_buf(),
|
||||
/*enable_codex_api_key_env*/ false,
|
||||
auth_credentials_store_mode,
|
||||
/*chatgpt_base_url*/ None,
|
||||
)
|
||||
.await
|
||||
.logout_with_revoke()
|
||||
.await
|
||||
let auth_dot_json = match load_auth_dot_json(codex_home, auth_credentials_store_mode) {
|
||||
Ok(auth_dot_json) => auth_dot_json,
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to load stored auth during logout: {err}");
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref()).await {
|
||||
tracing::warn!("failed to revoke auth tokens during logout: {err}");
|
||||
}
|
||||
logout_all_stores(codex_home, auth_credentials_store_mode)
|
||||
}
|
||||
|
||||
/// Writes an `auth.json` that contains only the API key.
|
||||
|
||||
@@ -16,5 +16,3 @@ pub use bedrock_api_key::login_with_bedrock_api_key;
|
||||
pub use error::RefreshTokenFailedError;
|
||||
pub use error::RefreshTokenFailedReason;
|
||||
pub use manager::*;
|
||||
pub(crate) use revoke::revoke_auth_tokens;
|
||||
pub(crate) use revoke::should_revoke_auth_tokens;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
//! Best-effort OAuth token revocation for managed auth cleanup.
|
||||
//! Best-effort OAuth token revocation used during logout.
|
||||
//!
|
||||
//! Managed ChatGPT auth stores OAuth tokens locally. Cleanup attempts to revoke
|
||||
//! the refresh token, falling back to the access token when no refresh token is
|
||||
//! available, and callers still complete their primary work if the revoke request
|
||||
//! fails.
|
||||
//! 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;
|
||||
@@ -52,7 +51,7 @@ struct RevokeTokenRequest<'a> {
|
||||
client_id: Option<&'static str>,
|
||||
}
|
||||
|
||||
pub(crate) async fn revoke_auth_tokens(
|
||||
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 {
|
||||
@@ -64,23 +63,6 @@ pub(crate) async fn revoke_auth_tokens(
|
||||
revoke_oauth_token(&client, endpoint.as_str(), token, kind, REVOKE_HTTP_TIMEOUT).await
|
||||
}
|
||||
|
||||
pub(crate) fn should_revoke_auth_tokens(
|
||||
auth_dot_json: Option<&AuthDotJson>,
|
||||
replacement_auth: &AuthDotJson,
|
||||
) -> bool {
|
||||
let Some((token, kind)) = auth_dot_json.and_then(revocable_token) else {
|
||||
return false;
|
||||
};
|
||||
let Some(replacement_tokens) = managed_chatgpt_tokens(replacement_auth) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
match kind {
|
||||
RevokeTokenKind::Access => replacement_tokens.access_token != token,
|
||||
RevokeTokenKind::Refresh => replacement_tokens.refresh_token != token,
|
||||
}
|
||||
}
|
||||
|
||||
fn revocable_token(auth_dot_json: &AuthDotJson) -> Option<(&str, RevokeTokenKind)> {
|
||||
let tokens = managed_chatgpt_tokens(auth_dot_json)?;
|
||||
if !tokens.refresh_token.is_empty() {
|
||||
|
||||
@@ -25,10 +25,7 @@ use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::auth::AuthDotJson;
|
||||
use crate::auth::load_auth_dot_json;
|
||||
use crate::auth::revoke_auth_tokens;
|
||||
use crate::auth::save_auth;
|
||||
use crate::auth::should_revoke_auth_tokens;
|
||||
use crate::default_client::originator;
|
||||
use crate::pkce::PkceCodes;
|
||||
use crate::pkce::generate_pkce;
|
||||
@@ -784,8 +781,7 @@ pub(crate) async fn exchange_code_for_tokens(
|
||||
})
|
||||
}
|
||||
|
||||
/// Persists exchanged credentials using the configured local auth store, then
|
||||
/// best-effort revokes any superseded managed ChatGPT tokens.
|
||||
/// Persists exchanged credentials using the configured local auth store.
|
||||
pub(crate) async fn persist_tokens_async(
|
||||
codex_home: &Path,
|
||||
api_key: Option<String>,
|
||||
@@ -796,14 +792,7 @@ pub(crate) async fn persist_tokens_async(
|
||||
) -> io::Result<()> {
|
||||
// Reuse existing synchronous logic but run it off the async runtime.
|
||||
let codex_home = codex_home.to_path_buf();
|
||||
let (previous_auth, auth) = tokio::task::spawn_blocking(move || {
|
||||
let previous_auth = match load_auth_dot_json(&codex_home, auth_credentials_store_mode) {
|
||||
Ok(auth) => auth,
|
||||
Err(err) => {
|
||||
warn!("failed to load previous auth before saving new login: {err}");
|
||||
None
|
||||
}
|
||||
};
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut tokens = TokenData {
|
||||
id_token: parse_chatgpt_jwt_claims(&id_token).map_err(io::Error::other)?,
|
||||
access_token,
|
||||
@@ -825,19 +814,10 @@ pub(crate) async fn persist_tokens_async(
|
||||
personal_access_token: None,
|
||||
bedrock_api_key: None,
|
||||
};
|
||||
save_auth(&codex_home, &auth, auth_credentials_store_mode)?;
|
||||
Ok::<_, io::Error>((previous_auth, auth))
|
||||
save_auth(&codex_home, &auth, auth_credentials_store_mode)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| io::Error::other(format!("persist task failed: {e}")))??;
|
||||
|
||||
if should_revoke_auth_tokens(previous_auth.as_ref(), &auth)
|
||||
&& let Err(err) = revoke_auth_tokens(previous_auth.as_ref()).await
|
||||
{
|
||||
warn!("failed to revoke superseded auth tokens after login: {err}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
.map_err(|e| io::Error::other(format!("persist task failed: {e}")))?
|
||||
}
|
||||
|
||||
fn compose_success_url(
|
||||
@@ -1171,28 +1151,6 @@ pub(crate) async fn obtain_api_key(
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::ffi::OsString;
|
||||
|
||||
use anyhow::Context;
|
||||
use base64::Engine;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
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;
|
||||
|
||||
use crate::auth::AuthDotJson;
|
||||
use crate::auth::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR;
|
||||
use crate::auth::load_auth_dot_json;
|
||||
use crate::auth::save_auth;
|
||||
use crate::token_data::TokenData;
|
||||
use crate::token_data::parse_chatgpt_jwt_claims;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::DEFAULT_ISSUER;
|
||||
@@ -1201,181 +1159,11 @@ mod tests {
|
||||
use super::html_escape;
|
||||
use super::is_missing_codex_entitlement_error;
|
||||
use super::parse_token_endpoint_error;
|
||||
use super::persist_tokens_async;
|
||||
use super::redact_sensitive_query_value;
|
||||
use super::redact_sensitive_url_parts;
|
||||
use super::render_login_error_page;
|
||||
use super::sanitize_url_for_logging;
|
||||
|
||||
#[serial_test::serial(logout_revoke)]
|
||||
#[tokio::test]
|
||||
async fn persist_tokens_async_revokes_previous_auth_without_failing_login() -> anyhow::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()?;
|
||||
save_auth(
|
||||
codex_home.path(),
|
||||
&chatgpt_auth("old-access", "old-refresh", "old-account"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
persist_tokens_async(
|
||||
codex_home.path(),
|
||||
/*api_key*/ None,
|
||||
jwt_for_account("new-account"),
|
||||
"new-access".to_string(),
|
||||
"new-refresh".to_string(),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let auth = load_auth_dot_json(codex_home.path(), AuthCredentialsStoreMode::File)?
|
||||
.context("auth.json should exist after login")?;
|
||||
assert_eq!(
|
||||
auth.tokens.context("new tokens should be persisted")?,
|
||||
TokenData {
|
||||
id_token: parse_chatgpt_jwt_claims(&jwt_for_account("new-account"))
|
||||
.expect("new JWT should parse"),
|
||||
access_token: "new-access".to_string(),
|
||||
refresh_token: "new-refresh".to_string(),
|
||||
account_id: Some("new-account".to_string()),
|
||||
}
|
||||
);
|
||||
|
||||
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": "old-refresh",
|
||||
"token_type_hint": "refresh_token",
|
||||
"client_id": crate::auth::CLIENT_ID,
|
||||
})
|
||||
);
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[serial_test::serial(logout_revoke)]
|
||||
#[tokio::test]
|
||||
async fn persist_tokens_async_does_not_revoke_reused_refresh_token() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let _env_guard = EnvGuard::set(
|
||||
REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR,
|
||||
format!("{}/oauth/revoke", server.uri()),
|
||||
);
|
||||
|
||||
let codex_home = tempdir()?;
|
||||
save_auth(
|
||||
codex_home.path(),
|
||||
&chatgpt_auth("old-access", "shared-refresh", "old-account"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
persist_tokens_async(
|
||||
codex_home.path(),
|
||||
/*api_key*/ None,
|
||||
jwt_for_account("new-account"),
|
||||
"new-access".to_string(),
|
||||
"shared-refresh".to_string(),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.context("failed to fetch revoke requests")?;
|
||||
assert_eq!(requests.len(), 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn chatgpt_auth(access_token: &str, refresh_token: &str, account_id: &str) -> AuthDotJson {
|
||||
AuthDotJson {
|
||||
auth_mode: Some(AuthMode::Chatgpt),
|
||||
openai_api_key: None,
|
||||
tokens: Some(TokenData {
|
||||
id_token: parse_chatgpt_jwt_claims(&jwt_for_account(account_id))
|
||||
.expect("test JWT should parse"),
|
||||
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 jwt_for_account(account_id: &str) -> String {
|
||||
let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
|
||||
let header_b64 = encode(br#"{"alg":"none","typ":"JWT"}"#);
|
||||
let payload_b64 = encode(
|
||||
serde_json::to_string(&json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": account_id,
|
||||
}
|
||||
}))
|
||||
.expect("payload should serialize")
|
||||
.as_bytes(),
|
||||
);
|
||||
let signature_b64 = encode(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: this test executes serially with other revoke tests.
|
||||
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 before other revoke tests run.
|
||||
unsafe {
|
||||
match &self.original {
|
||||
Some(value) => std::env::set_var(self.key, value),
|
||||
None => std::env::remove_var(self.key),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_token_endpoint_error_prefers_error_description() {
|
||||
let detail = parse_token_endpoint_error(
|
||||
|
||||
@@ -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