mirror of
https://github.com/openai/codex.git
synced 2026-04-24 22:54:54 +00:00
We've continued to receive reports from users that they're seeing the error message "Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again." This PR fixes two holes in the token refresh logic that lead to this condition. Background: A previous change in token refresh introduced the `UnauthorizedRecovery` object. It implements a state machine in the core agent loop that first performs a load of the on-disk auth information guarded by a check for matching account ID. If it finds that the on-disk version has been updated by another instance of codex, it uses the reloaded auth tokens. If the on-disk version hasn't been updated, it issues a refresh request from the token authority. There are two problems that this PR addresses: Problem 1: We weren't doing the same thing for the code path used by the app server interface. This PR effectively replicates the `UnauthorizedRecovery` logic for that code path. Problem 2: The `UnauthorizedRecovery` logic contained a hole in the `ReloadOutcome::Skipped` case. Here's the scenario. A user starts two instances of the CLI. Instance 1 is active (working on a task), instance 2 is idle. Both instances have the same in-memory cached tokens. The user then runs `codex logout` or `codex login` to log in to a separate account, which overwrites the `auth.json` file. Instance 1 receives a 401 and refreshes its token, but it doesn't write the new token to the `auth.json` file because the account ID doesn't match. Instance 2 is later activated and presented with a new task. It immediately hits a 401 and attempts to refresh its token but fails because its cached refresh token is now invalid. To avoid this situation, I've changed the logic to immediately fail a token refresh if the user has since logged out or logged in to another account. This will still be seen as an error by the user, but the cause will be clearer. I also took this opportunity to clean up the names of existing functions to make their roles clearer. * `try_refresh_token` is renamed `request_chatgpt_token_refresh` * the existing `refresh_token` is renamed `refresh_token_from_authority` (there's a new higher-level function named `refresh_token` now) * `refresh_tokens` is renamed `refresh_and_persist_chatgpt_token`, and it now implicitly reloads * `update_tokens` is renamed `persist_tokens`
801 lines
24 KiB
Rust
801 lines
24 KiB
Rust
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use base64::Engine;
|
|
use chrono::Duration;
|
|
use chrono::Utc;
|
|
use codex_app_server_protocol::AuthMode;
|
|
use codex_core::AuthManager;
|
|
use codex_core::auth::AuthCredentialsStoreMode;
|
|
use codex_core::auth::AuthDotJson;
|
|
use codex_core::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
|
|
use codex_core::auth::RefreshTokenError;
|
|
use codex_core::auth::load_auth_dot_json;
|
|
use codex_core::auth::save_auth;
|
|
use codex_core::error::RefreshTokenFailedReason;
|
|
use codex_core::token_data::IdTokenInfo;
|
|
use codex_core::token_data::TokenData;
|
|
use core_test_support::skip_if_no_network;
|
|
use pretty_assertions::assert_eq;
|
|
use serde::Serialize;
|
|
use serde_json::json;
|
|
use std::ffi::OsString;
|
|
use std::sync::Arc;
|
|
use tempfile::TempDir;
|
|
use wiremock::Mock;
|
|
use wiremock::MockServer;
|
|
use wiremock::ResponseTemplate;
|
|
use wiremock::matchers::method;
|
|
use wiremock::matchers::path;
|
|
|
|
const INITIAL_ACCESS_TOKEN: &str = "initial-access-token";
|
|
const INITIAL_REFRESH_TOKEN: &str = "initial-refresh-token";
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn refresh_token_succeeds_updates_storage() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"access_token": "new-access-token",
|
|
"refresh_token": "new-refresh-token"
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ctx = RefreshTokenTestContext::new(&server)?;
|
|
let initial_last_refresh = Utc::now() - Duration::days(1);
|
|
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
|
let initial_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(initial_tokens.clone()),
|
|
last_refresh: Some(initial_last_refresh),
|
|
};
|
|
ctx.write_auth(&initial_auth)?;
|
|
|
|
ctx.auth_manager
|
|
.refresh_token_from_authority()
|
|
.await
|
|
.context("refresh should succeed")?;
|
|
|
|
let refreshed_tokens = TokenData {
|
|
access_token: "new-access-token".to_string(),
|
|
refresh_token: "new-refresh-token".to_string(),
|
|
..initial_tokens.clone()
|
|
};
|
|
let stored = ctx.load_auth()?;
|
|
let tokens = stored.tokens.as_ref().context("tokens should exist")?;
|
|
assert_eq!(tokens, &refreshed_tokens);
|
|
let refreshed_at = stored
|
|
.last_refresh
|
|
.as_ref()
|
|
.context("last_refresh should be recorded")?;
|
|
assert!(
|
|
*refreshed_at >= initial_last_refresh,
|
|
"last_refresh should advance"
|
|
);
|
|
|
|
let cached_auth = ctx
|
|
.auth_manager
|
|
.auth()
|
|
.await
|
|
.context("auth should be cached")?;
|
|
let cached = cached_auth
|
|
.get_token_data()
|
|
.context("token data should be cached")?;
|
|
assert_eq!(cached, refreshed_tokens);
|
|
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn refresh_token_refreshes_when_auth_is_unchanged() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"access_token": "new-access-token",
|
|
"refresh_token": "new-refresh-token"
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ctx = RefreshTokenTestContext::new(&server)?;
|
|
let initial_last_refresh = Utc::now() - Duration::days(1);
|
|
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
|
let initial_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(initial_tokens.clone()),
|
|
last_refresh: Some(initial_last_refresh),
|
|
};
|
|
ctx.write_auth(&initial_auth)?;
|
|
|
|
ctx.auth_manager
|
|
.refresh_token()
|
|
.await
|
|
.context("refresh should succeed")?;
|
|
|
|
let refreshed_tokens = TokenData {
|
|
access_token: "new-access-token".to_string(),
|
|
refresh_token: "new-refresh-token".to_string(),
|
|
..initial_tokens.clone()
|
|
};
|
|
let stored = ctx.load_auth()?;
|
|
let tokens = stored.tokens.as_ref().context("tokens should exist")?;
|
|
assert_eq!(tokens, &refreshed_tokens);
|
|
let refreshed_at = stored
|
|
.last_refresh
|
|
.as_ref()
|
|
.context("last_refresh should be recorded")?;
|
|
assert!(
|
|
*refreshed_at >= initial_last_refresh,
|
|
"last_refresh should advance"
|
|
);
|
|
|
|
let cached_auth = ctx
|
|
.auth_manager
|
|
.auth()
|
|
.await
|
|
.context("auth should be cached")?;
|
|
let cached = cached_auth
|
|
.get_token_data()
|
|
.context("token data should be cached")?;
|
|
assert_eq!(cached, refreshed_tokens);
|
|
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn refresh_token_skips_refresh_when_auth_changed() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
let ctx = RefreshTokenTestContext::new(&server)?;
|
|
|
|
let initial_last_refresh = Utc::now() - Duration::days(1);
|
|
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
|
let initial_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(initial_tokens),
|
|
last_refresh: Some(initial_last_refresh),
|
|
};
|
|
ctx.write_auth(&initial_auth)?;
|
|
|
|
let disk_tokens = build_tokens("disk-access-token", "disk-refresh-token");
|
|
let disk_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(disk_tokens.clone()),
|
|
last_refresh: Some(initial_last_refresh),
|
|
};
|
|
save_auth(
|
|
ctx.codex_home.path(),
|
|
&disk_auth,
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
ctx.auth_manager
|
|
.refresh_token()
|
|
.await
|
|
.context("refresh should be skipped")?;
|
|
|
|
let stored = ctx.load_auth()?;
|
|
assert_eq!(stored, disk_auth);
|
|
|
|
let cached_auth = ctx
|
|
.auth_manager
|
|
.auth_cached()
|
|
.context("auth should be cached")?;
|
|
let cached_tokens = cached_auth
|
|
.get_token_data()
|
|
.context("token data should be cached")?;
|
|
assert_eq!(cached_tokens, disk_tokens);
|
|
|
|
let requests = server.received_requests().await.unwrap_or_default();
|
|
assert!(requests.is_empty(), "expected no refresh token requests");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn refresh_token_errors_on_account_mismatch() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"access_token": "recovered-access-token",
|
|
"refresh_token": "recovered-refresh-token"
|
|
})))
|
|
.expect(0)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ctx = RefreshTokenTestContext::new(&server)?;
|
|
let initial_last_refresh = Utc::now() - Duration::days(1);
|
|
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
|
let initial_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(initial_tokens.clone()),
|
|
last_refresh: Some(initial_last_refresh),
|
|
};
|
|
ctx.write_auth(&initial_auth)?;
|
|
|
|
let mut disk_tokens = build_tokens("disk-access-token", "disk-refresh-token");
|
|
disk_tokens.account_id = Some("other-account".to_string());
|
|
let disk_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(disk_tokens),
|
|
last_refresh: Some(initial_last_refresh),
|
|
};
|
|
save_auth(
|
|
ctx.codex_home.path(),
|
|
&disk_auth,
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let err = ctx
|
|
.auth_manager
|
|
.refresh_token()
|
|
.await
|
|
.err()
|
|
.context("refresh should fail due to account mismatch")?;
|
|
assert_eq!(err.failed_reason(), Some(RefreshTokenFailedReason::Other));
|
|
|
|
let stored = ctx.load_auth()?;
|
|
assert_eq!(stored, disk_auth);
|
|
|
|
let requests = server.received_requests().await.unwrap_or_default();
|
|
assert!(requests.is_empty(), "expected no refresh token requests");
|
|
|
|
let cached_after = ctx
|
|
.auth_manager
|
|
.auth_cached()
|
|
.context("auth should be cached after refresh")?;
|
|
let cached_after_tokens = cached_after
|
|
.get_token_data()
|
|
.context("token data should remain cached")?;
|
|
assert_eq!(cached_after_tokens, initial_tokens);
|
|
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn returns_fresh_tokens_as_is() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"access_token": "new-access-token",
|
|
"refresh_token": "new-refresh-token"
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ctx = RefreshTokenTestContext::new(&server)?;
|
|
let initial_last_refresh = Utc::now() - Duration::days(1);
|
|
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
|
let initial_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(initial_tokens.clone()),
|
|
last_refresh: Some(initial_last_refresh),
|
|
};
|
|
ctx.write_auth(&initial_auth)?;
|
|
|
|
let cached_auth = ctx
|
|
.auth_manager
|
|
.auth()
|
|
.await
|
|
.context("auth should be cached")?;
|
|
let cached = cached_auth
|
|
.get_token_data()
|
|
.context("token data should remain cached")?;
|
|
assert_eq!(cached, initial_tokens);
|
|
|
|
let stored = ctx.load_auth()?;
|
|
assert_eq!(stored, initial_auth);
|
|
|
|
let requests = server.received_requests().await.unwrap_or_default();
|
|
assert!(requests.is_empty(), "expected no refresh token requests");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn refreshes_token_when_last_refresh_is_stale() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"access_token": "new-access-token",
|
|
"refresh_token": "new-refresh-token"
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ctx = RefreshTokenTestContext::new(&server)?;
|
|
let stale_refresh = Utc::now() - Duration::days(9);
|
|
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
|
let initial_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(initial_tokens.clone()),
|
|
last_refresh: Some(stale_refresh),
|
|
};
|
|
ctx.write_auth(&initial_auth)?;
|
|
|
|
let cached_auth = ctx
|
|
.auth_manager
|
|
.auth()
|
|
.await
|
|
.context("auth should be cached")?;
|
|
let refreshed_tokens = TokenData {
|
|
access_token: "new-access-token".to_string(),
|
|
refresh_token: "new-refresh-token".to_string(),
|
|
..initial_tokens.clone()
|
|
};
|
|
let cached = cached_auth
|
|
.get_token_data()
|
|
.context("token data should refresh")?;
|
|
assert_eq!(cached, refreshed_tokens);
|
|
|
|
let stored = ctx.load_auth()?;
|
|
let tokens = stored.tokens.as_ref().context("tokens should exist")?;
|
|
assert_eq!(tokens, &refreshed_tokens);
|
|
let refreshed_at = stored
|
|
.last_refresh
|
|
.as_ref()
|
|
.context("last_refresh should be recorded")?;
|
|
assert!(
|
|
*refreshed_at >= stale_refresh,
|
|
"last_refresh should advance"
|
|
);
|
|
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn refresh_token_returns_permanent_error_for_expired_refresh_token() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
|
|
"error": {
|
|
"code": "refresh_token_expired"
|
|
}
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ctx = RefreshTokenTestContext::new(&server)?;
|
|
let initial_last_refresh = Utc::now() - Duration::days(1);
|
|
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
|
let initial_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(initial_tokens.clone()),
|
|
last_refresh: Some(initial_last_refresh),
|
|
};
|
|
ctx.write_auth(&initial_auth)?;
|
|
|
|
let err = ctx
|
|
.auth_manager
|
|
.refresh_token_from_authority()
|
|
.await
|
|
.err()
|
|
.context("refresh should fail")?;
|
|
assert_eq!(err.failed_reason(), Some(RefreshTokenFailedReason::Expired));
|
|
|
|
let stored = ctx.load_auth()?;
|
|
assert_eq!(stored, initial_auth);
|
|
let cached_auth = ctx
|
|
.auth_manager
|
|
.auth()
|
|
.await
|
|
.context("auth should remain cached")?;
|
|
let cached = cached_auth
|
|
.get_token_data()
|
|
.context("token data should remain cached")?;
|
|
assert_eq!(cached, initial_tokens);
|
|
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn refresh_token_returns_transient_error_on_server_failure() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(500).set_body_json(json!({
|
|
"error": "temporary-failure"
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ctx = RefreshTokenTestContext::new(&server)?;
|
|
let initial_last_refresh = Utc::now() - Duration::days(1);
|
|
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
|
let initial_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(initial_tokens.clone()),
|
|
last_refresh: Some(initial_last_refresh),
|
|
};
|
|
ctx.write_auth(&initial_auth)?;
|
|
|
|
let err = ctx
|
|
.auth_manager
|
|
.refresh_token_from_authority()
|
|
.await
|
|
.err()
|
|
.context("refresh should fail")?;
|
|
assert!(matches!(err, RefreshTokenError::Transient(_)));
|
|
assert_eq!(err.failed_reason(), None);
|
|
|
|
let stored = ctx.load_auth()?;
|
|
assert_eq!(stored, initial_auth);
|
|
let cached_auth = ctx
|
|
.auth_manager
|
|
.auth()
|
|
.await
|
|
.context("auth should remain cached")?;
|
|
let cached = cached_auth
|
|
.get_token_data()
|
|
.context("token data should remain cached")?;
|
|
assert_eq!(cached, initial_tokens);
|
|
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"access_token": "recovered-access-token",
|
|
"refresh_token": "recovered-refresh-token"
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ctx = RefreshTokenTestContext::new(&server)?;
|
|
let initial_last_refresh = Utc::now() - Duration::days(1);
|
|
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
|
let initial_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(initial_tokens.clone()),
|
|
last_refresh: Some(initial_last_refresh),
|
|
};
|
|
ctx.write_auth(&initial_auth)?;
|
|
|
|
let disk_tokens = build_tokens("disk-access-token", "disk-refresh-token");
|
|
let disk_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(disk_tokens.clone()),
|
|
last_refresh: Some(initial_last_refresh),
|
|
};
|
|
save_auth(
|
|
ctx.codex_home.path(),
|
|
&disk_auth,
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let cached_before = ctx
|
|
.auth_manager
|
|
.auth_cached()
|
|
.expect("auth should be cached");
|
|
let cached_before_tokens = cached_before
|
|
.get_token_data()
|
|
.context("token data should be cached")?;
|
|
assert_eq!(cached_before_tokens, initial_tokens);
|
|
|
|
let mut recovery = ctx.auth_manager.unauthorized_recovery();
|
|
assert!(recovery.has_next());
|
|
|
|
recovery.next().await?;
|
|
|
|
let cached_after = ctx
|
|
.auth_manager
|
|
.auth_cached()
|
|
.expect("auth should be cached after reload");
|
|
let cached_after_tokens = cached_after
|
|
.get_token_data()
|
|
.context("token data should reload")?;
|
|
assert_eq!(cached_after_tokens, disk_tokens);
|
|
|
|
let requests = server.received_requests().await.unwrap_or_default();
|
|
assert!(requests.is_empty(), "expected no refresh token requests");
|
|
|
|
recovery.next().await?;
|
|
|
|
let refreshed_tokens = TokenData {
|
|
access_token: "recovered-access-token".to_string(),
|
|
refresh_token: "recovered-refresh-token".to_string(),
|
|
..disk_tokens.clone()
|
|
};
|
|
let stored = ctx.load_auth()?;
|
|
let tokens = stored.tokens.as_ref().context("tokens should exist")?;
|
|
assert_eq!(tokens, &refreshed_tokens);
|
|
|
|
let cached_auth = ctx
|
|
.auth_manager
|
|
.auth()
|
|
.await
|
|
.expect("auth should be cached");
|
|
let cached_tokens = cached_auth
|
|
.get_token_data()
|
|
.context("token data should be cached")?;
|
|
assert_eq!(cached_tokens, refreshed_tokens);
|
|
assert!(!recovery.has_next());
|
|
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn unauthorized_recovery_errors_on_account_mismatch() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth/token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"access_token": "recovered-access-token",
|
|
"refresh_token": "recovered-refresh-token"
|
|
})))
|
|
.expect(0)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ctx = RefreshTokenTestContext::new(&server)?;
|
|
let initial_last_refresh = Utc::now() - Duration::days(1);
|
|
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
|
|
let initial_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(initial_tokens.clone()),
|
|
last_refresh: Some(initial_last_refresh),
|
|
};
|
|
ctx.write_auth(&initial_auth)?;
|
|
|
|
let mut disk_tokens = build_tokens("disk-access-token", "disk-refresh-token");
|
|
disk_tokens.account_id = Some("other-account".to_string());
|
|
let disk_auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::Chatgpt),
|
|
openai_api_key: None,
|
|
tokens: Some(disk_tokens),
|
|
last_refresh: Some(initial_last_refresh),
|
|
};
|
|
save_auth(
|
|
ctx.codex_home.path(),
|
|
&disk_auth,
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let cached_before = ctx
|
|
.auth_manager
|
|
.auth_cached()
|
|
.expect("auth should be cached");
|
|
let cached_before_tokens = cached_before
|
|
.get_token_data()
|
|
.context("token data should be cached")?;
|
|
assert_eq!(cached_before_tokens, initial_tokens);
|
|
|
|
let mut recovery = ctx.auth_manager.unauthorized_recovery();
|
|
assert!(recovery.has_next());
|
|
|
|
let err = recovery
|
|
.next()
|
|
.await
|
|
.err()
|
|
.context("recovery should fail due to account mismatch")?;
|
|
assert_eq!(err.failed_reason(), Some(RefreshTokenFailedReason::Other));
|
|
|
|
let stored = ctx.load_auth()?;
|
|
assert_eq!(stored, disk_auth);
|
|
|
|
let requests = server.received_requests().await.unwrap_or_default();
|
|
assert!(requests.is_empty(), "expected no refresh token requests");
|
|
|
|
let cached_after = ctx
|
|
.auth_manager
|
|
.auth_cached()
|
|
.context("auth should remain cached after refresh")?;
|
|
let cached_after_tokens = cached_after
|
|
.get_token_data()
|
|
.context("token data should remain cached")?;
|
|
assert_eq!(cached_after_tokens, initial_tokens);
|
|
|
|
server.verify().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[serial_test::serial(auth_refresh)]
|
|
#[tokio::test]
|
|
async fn unauthorized_recovery_requires_chatgpt_auth() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = MockServer::start().await;
|
|
let ctx = RefreshTokenTestContext::new(&server)?;
|
|
let auth = AuthDotJson {
|
|
auth_mode: Some(AuthMode::ApiKey),
|
|
openai_api_key: Some("sk-test".to_string()),
|
|
tokens: None,
|
|
last_refresh: None,
|
|
};
|
|
ctx.write_auth(&auth)?;
|
|
|
|
let mut recovery = ctx.auth_manager.unauthorized_recovery();
|
|
assert!(!recovery.has_next());
|
|
|
|
let err = recovery
|
|
.next()
|
|
.await
|
|
.err()
|
|
.context("recovery should fail")?;
|
|
assert_eq!(err.failed_reason(), Some(RefreshTokenFailedReason::Other));
|
|
|
|
let requests = server.received_requests().await.unwrap_or_default();
|
|
assert!(requests.is_empty(), "expected no refresh token requests");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
struct RefreshTokenTestContext {
|
|
codex_home: TempDir,
|
|
auth_manager: Arc<AuthManager>,
|
|
_env_guard: EnvGuard,
|
|
}
|
|
|
|
impl RefreshTokenTestContext {
|
|
fn new(server: &MockServer) -> Result<Self> {
|
|
let codex_home = TempDir::new()?;
|
|
|
|
let endpoint = format!("{}/oauth/token", server.uri());
|
|
let env_guard = EnvGuard::set(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, endpoint);
|
|
|
|
let auth_manager = AuthManager::shared(
|
|
codex_home.path().to_path_buf(),
|
|
false,
|
|
AuthCredentialsStoreMode::File,
|
|
);
|
|
|
|
Ok(Self {
|
|
codex_home,
|
|
auth_manager,
|
|
_env_guard: env_guard,
|
|
})
|
|
}
|
|
|
|
fn load_auth(&self) -> Result<AuthDotJson> {
|
|
load_auth_dot_json(self.codex_home.path(), AuthCredentialsStoreMode::File)
|
|
.context("load auth.json")?
|
|
.context("auth.json should exist")
|
|
}
|
|
|
|
fn write_auth(&self, auth_dot_json: &AuthDotJson) -> Result<()> {
|
|
save_auth(
|
|
self.codex_home.path(),
|
|
auth_dot_json,
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
self.auth_manager.reload();
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
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),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn minimal_jwt() -> String {
|
|
#[derive(Serialize)]
|
|
struct Header {
|
|
alg: &'static str,
|
|
typ: &'static str,
|
|
}
|
|
|
|
let header = Header {
|
|
alg: "none",
|
|
typ: "JWT",
|
|
};
|
|
let payload = json!({ "sub": "user-123" });
|
|
|
|
fn b64(data: &[u8]) -> String {
|
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
|
|
}
|
|
|
|
let header_bytes = match serde_json::to_vec(&header) {
|
|
Ok(bytes) => bytes,
|
|
Err(err) => panic!("serialize header: {err}"),
|
|
};
|
|
let payload_bytes = match serde_json::to_vec(&payload) {
|
|
Ok(bytes) => bytes,
|
|
Err(err) => panic!("serialize payload: {err}"),
|
|
};
|
|
let header_b64 = b64(&header_bytes);
|
|
let payload_b64 = b64(&payload_bytes);
|
|
let signature_b64 = b64(b"sig");
|
|
format!("{header_b64}.{payload_b64}.{signature_b64}")
|
|
}
|
|
|
|
fn build_tokens(access_token: &str, refresh_token: &str) -> TokenData {
|
|
let mut id_token = IdTokenInfo::default();
|
|
id_token.raw_jwt = minimal_jwt();
|
|
TokenData {
|
|
id_token,
|
|
access_token: access_token.to_string(),
|
|
refresh_token: refresh_token.to_string(),
|
|
account_id: Some("account-id".to_string()),
|
|
}
|
|
}
|