From b901ed1d552627ebef396ee83326348abfe91acc Mon Sep 17 00:00:00 2001 From: Cooper Gamble Date: Tue, 19 May 2026 20:28:17 +0000 Subject: [PATCH] [codex-cli] match web access token refresh window [ci changed_files] --- codex-rs/exec/src/lib.rs | 5 ++- codex-rs/login/src/auth/manager.rs | 27 +++++++++++---- codex-rs/login/tests/suite/auth_refresh.rs | 38 ++++++++++++++++++++-- codex-rs/tui/src/lib.rs | 5 ++- 4 files changed, 64 insertions(+), 11 deletions(-) diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 247e0cf3d2..fb1006ce88 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -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( diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 6c08d02f0b..dd3ea25dd0 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -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(()); } diff --git a/codex-rs/login/tests/suite/auth_refresh.rs b/codex-rs/login/tests/suite/auth_refresh.rs index f4a68b0fa0..228d8698ec 100644 --- a/codex-rs/login/tests/suite/auth_refresh.rs +++ b/codex-rs/login/tests/suite/auth_refresh.rs @@ -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<()> { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 3ae0ca5d83..4a3622d0d8 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -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(