[codex-cli] match web access token refresh window [ci changed_files]

This commit is contained in:
Cooper Gamble
2026-05-19 20:28:17 +00:00
parent d17c381d3f
commit b901ed1d55
4 changed files with 64 additions and 11 deletions

View File

@@ -371,7 +371,10 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
Some(chatgpt_base_url.clone()),
)
.await;
if let Err(err) = cloud_auth_manager.refresh_managed_chatgpt_token().await {
if let Err(err) = cloud_auth_manager
.refresh_managed_chatgpt_token_if_near_expiry()
.await
{
warn!("failed to proactively refresh ChatGPT access token during CLI startup: {err}");
}
let cloud_requirements = cloud_requirements_loader(

View File

@@ -84,6 +84,7 @@ struct ChatgptAuthState {
}
const TOKEN_REFRESH_INTERVAL: i64 = 8;
const CHATGPT_ACCESS_TOKEN_REFRESH_WINDOW_MINUTES: i64 = 5;
const REFRESH_TOKEN_EXPIRED_MESSAGE: &str = "Your access token could not be refreshed because your refresh token has expired. Please log out and sign in again.";
const REFRESH_TOKEN_REUSED_MESSAGE: &str = "Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.";
@@ -1715,13 +1716,27 @@ impl AuthManager {
}
}
/// Refresh managed ChatGPT auth even when its access token has not expired yet.
/// Refresh managed ChatGPT auth when its access token is nearly expired.
///
/// CLI startup uses this to begin sessions with a freshly issued access token.
/// Other auth modes either cannot be refreshed locally or have separate refresh
/// ownership, so they intentionally no-op here.
pub async fn refresh_managed_chatgpt_token(&self) -> Result<(), RefreshTokenError> {
if !matches!(self.auth_cached(), Some(CodexAuth::Chatgpt(_))) {
/// CLI startup uses the same five-minute refresh window as ChatGPT web so a
/// new session does not begin with a token that is about to expire. Other auth
/// modes either cannot be refreshed locally or have separate refresh ownership,
/// so they intentionally no-op here.
pub async fn refresh_managed_chatgpt_token_if_near_expiry(
&self,
) -> Result<(), RefreshTokenError> {
let Some(CodexAuth::Chatgpt(chatgpt_auth)) = self.auth_cached() else {
return Ok(());
};
let should_refresh = chatgpt_auth
.current_token_data()
.and_then(|tokens| parse_jwt_expiration(&tokens.access_token).ok().flatten())
.is_some_and(|expires_at| {
expires_at
<= Utc::now()
+ chrono::Duration::minutes(CHATGPT_ACCESS_TOKEN_REFRESH_WINDOW_MINUTES)
});
if !should_refresh {
return Ok(());
}

View File

@@ -160,7 +160,7 @@ async fn refresh_token_refreshes_when_auth_is_unchanged() -> Result<()> {
#[serial_test::serial(auth_refresh)]
#[tokio::test]
async fn refresh_managed_chatgpt_token_refreshes_even_when_auth_is_fresh() -> Result<()> {
async fn refresh_managed_chatgpt_token_refreshes_when_auth_is_near_expiry() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = MockServer::start().await;
@@ -176,7 +176,8 @@ async fn refresh_managed_chatgpt_token_refreshes_even_when_auth_is_fresh() -> Re
let ctx = RefreshTokenTestContext::new(&server).await?;
let initial_last_refresh = Utc::now();
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
let near_expiry_access_token = access_token_with_expiration(Utc::now() + Duration::minutes(4));
let initial_tokens = build_tokens(&near_expiry_access_token, INITIAL_REFRESH_TOKEN);
let initial_auth = AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
@@ -187,7 +188,7 @@ async fn refresh_managed_chatgpt_token_refreshes_even_when_auth_is_fresh() -> Re
ctx.write_auth(&initial_auth).await?;
ctx.auth_manager
.refresh_managed_chatgpt_token()
.refresh_managed_chatgpt_token_if_near_expiry()
.await
.context("managed ChatGPT refresh should succeed")?;
@@ -212,6 +213,37 @@ async fn refresh_managed_chatgpt_token_refreshes_even_when_auth_is_fresh() -> Re
Ok(())
}
#[serial_test::serial(auth_refresh)]
#[tokio::test]
async fn refresh_managed_chatgpt_token_skips_auth_outside_refresh_window() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = MockServer::start().await;
let ctx = RefreshTokenTestContext::new(&server).await?;
let initial_last_refresh = Utc::now();
let fresh_access_token = access_token_with_expiration(Utc::now() + Duration::minutes(6));
let initial_tokens = build_tokens(&fresh_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),
agent_identity: None,
};
ctx.write_auth(&initial_auth).await?;
ctx.auth_manager
.refresh_managed_chatgpt_token_if_near_expiry()
.await
.context("managed ChatGPT refresh should no-op")?;
assert_eq!(ctx.load_auth()?, 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 refresh_token_skips_refresh_when_auth_changed() -> Result<()> {

View File

@@ -994,7 +994,10 @@ pub async fn run_main(
Some(chatgpt_base_url.clone()),
)
.await;
if let Err(err) = cloud_auth_manager.refresh_managed_chatgpt_token().await {
if let Err(err) = cloud_auth_manager
.refresh_managed_chatgpt_token_if_near_expiry()
.await
{
warn!("failed to proactively refresh ChatGPT access token during CLI startup: {err}");
}
let cloud_requirements = cloud_requirements_loader(