login: treat provider auth refresh_interval_ms=0 as no auto-refresh (#16480)

## Why

Follow-up to #16288: the new dynamic provider auth token flow currently
defaults `refresh_interval_ms` to a non-zero value and rejects `0`
entirely.

For command-backed bearer auth, `0` should mean "never auto-refresh".
That lets callers keep using the cached token until the backend actually
returns `401 Unauthorized`, at which point Codex can rerun the auth
command as part of the existing retry path.

## What changed

- changed `ModelProviderAuthInfo.refresh_interval_ms` to accept `0` and
documented that value as disabling proactive refresh
- updated the external bearer token refresher to treat
`refresh_interval_ms = 0` as an indefinitely reusable cached token,
while still rerunning the auth command during unauthorized recovery
- regenerated `core/config.schema.json` so the schema minimum is `0` and
the new behavior is described in the field docs
- added coverage for both config deserialization and the no-auto-refresh
plus `401` recovery behavior

## How tested

- `cargo test -p codex-protocol`
- `cargo test -p codex-login`
- `cargo test -p codex-core test_deserialize_provider_auth_config_`
This commit is contained in:
Michael Bolin
2026-04-01 15:30:10 -07:00
committed by GitHub
parent 1b711a5501
commit f83f3fa2a6
7 changed files with 73 additions and 33 deletions
+23 -1
View File
@@ -287,6 +287,26 @@ async fn external_bearer_only_auth_manager_uses_cached_provider_token() {
assert_eq!(manager.get_api_auth_mode(), Some(ApiAuthMode::ApiKey));
}
#[tokio::test]
async fn external_bearer_only_auth_manager_disables_auto_refresh_when_interval_is_zero() {
let script = ProviderAuthScript::new(&["provider-token", "next-token"]).unwrap();
let mut auth_config = script.auth_config();
auth_config.refresh_interval_ms = 0;
let manager = AuthManager::external_bearer_only(auth_config);
let first = manager
.auth()
.await
.and_then(|auth| auth.api_key().map(str::to_string));
let second = manager
.auth()
.await
.and_then(|auth| auth.api_key().map(str::to_string));
assert_eq!(first.as_deref(), Some("provider-token"));
assert_eq!(second.as_deref(), Some("provider-token"));
}
#[tokio::test]
async fn external_bearer_only_auth_manager_returns_none_when_command_fails() {
let script = ProviderAuthScript::new_failing().unwrap();
@@ -298,7 +318,9 @@ async fn external_bearer_only_auth_manager_returns_none_when_command_fails() {
#[tokio::test]
async fn unauthorized_recovery_uses_external_refresh_for_bearer_manager() {
let script = ProviderAuthScript::new(&["provider-token", "refreshed-provider-token"]).unwrap();
let manager = AuthManager::external_bearer_only(script.auth_config());
let mut auth_config = script.auth_config();
auth_config.refresh_interval_ms = 0;
let manager = AuthManager::external_bearer_only(auth_config);
let initial_token = manager
.auth()
.await
+17 -11
View File
@@ -35,18 +35,24 @@ impl ExternalAuth for BearerTokenRefresher {
async fn resolve(&self) -> io::Result<Option<ExternalAuthTokens>> {
let access_token = {
let mut cached = self.state.cached_token.lock().await;
if let Some(cached_token) = cached.as_ref()
&& cached_token.fetched_at.elapsed() < self.state.config.refresh_interval()
{
cached_token.access_token.clone()
} else {
let access_token = run_provider_auth_command(&self.state.config).await?;
*cached = Some(CachedExternalBearerToken {
access_token: access_token.clone(),
fetched_at: Instant::now(),
});
access_token
if let Some(cached_token) = cached.as_ref() {
let should_use_cached_token = match self.state.config.refresh_interval() {
Some(refresh_interval) => cached_token.fetched_at.elapsed() < refresh_interval,
None => true,
};
if should_use_cached_token {
return Ok(Some(ExternalAuthTokens::access_token_only(
cached_token.access_token.clone(),
)));
}
}
let access_token = run_provider_auth_command(&self.state.config).await?;
*cached = Some(CachedExternalBearerToken {
access_token: access_token.clone(),
fetched_at: Instant::now(),
});
access_token
};
Ok(Some(ExternalAuthTokens::access_token_only(access_token)))
}