Watch
1
0
Fork
You've already forked souveraine
0

claude: adopt rotated credentials instead of failing until restart

The credential file is shared with `claude` itself and its refresh token is
one-time-use, so whichever process refreshes first rotates the other's copy to
spent. On a failed refresh the provider kept the stale token and 400d every
turn until the service was restarted by hand — measured 2026-08-05, invalid_grant
from 00:03 onward while the file on disk already held a valid pair written at
00:08.

Re-read the file on refresh failure and adopt the token when it is different and
still live. oauth/refresh.rs already does this for the Codex path; the Claude
path never got it.
This commit is contained in:
Fimeg 2026-08-04 20:25:24 -04:00
commit bc09480375

View file

@ -239,7 +239,21 @@ impl ClaudeSubscriptionProvider {
.is_none_or(|exp| exp <= now_ms() + TOKEN_REFRESH_BUFFER.as_millis() as u64); .is_none_or(|exp| exp <= now_ms() + TOKEN_REFRESH_BUFFER.as_millis() as u64);
if access_empty || expiring { if access_empty || expiring {
if let Err(e) = refresh_token(&self.http, &self.user_agent, &mut token).await { if let Err(e) = refresh_token(&self.http, &self.user_agent, &mut token).await {
warn!("claude token refresh failed: {e}"); // Refresh tokens are one-time-use, and this credential file is
// shared with `claude` itself. Whichever process refreshes
// first rotates the token out from under the other, so a
// refresh failing here usually means our copy — read once at
// startup — is simply the spent half, not that anything was
// revoked. Re-read the file before giving up: if it now holds a
// newer, still-valid token, adopt it. Without this the provider
// 400s on every turn until the service is restarted by hand.
match reread_credentials(&token) {
Some(rotated) => {
info!("claude token refresh failed ({e}); adopted rotated credentials");
*token = rotated;
}
None => warn!("claude token refresh failed: {e}"),
}
} }
} }
token token
@ -390,6 +404,29 @@ impl LlmProvider for ClaudeSubscriptionProvider {
// ── token refresh ========================================================== // ── token refresh ==========================================================
/// Re-read the credential file behind `current`, returning it only if it now
/// holds a *different* and still-valid access token.
///
/// This is the recovery path for a lost refresh race: `claude` (or another
/// souveraine) rotated the one-time-use refresh token first, so ours is spent
/// while the file on disk already carries the winner's fresh pair. Adopting it
/// is strictly better than failing, since the alternative is a provider that
/// cannot recover without a restart.
///
/// Validity here is plain expiry, not the proactive refresh window: a token
/// with two minutes left still answers, and the next call will refresh it with
/// the rotated refresh token we picked up alongside it.
fn reread_credentials(current: &TokenState) -> Option<TokenState> {
let path = current.source_path.as_ref()?;
let rotated = load_credentials(path).ok()?;
let is_newer = rotated
.access_token
.as_deref()
.is_some_and(|t| !t.is_empty() && Some(t) != current.access_token.as_deref());
let is_live = rotated.expires_at_ms.is_some_and(|exp| exp > now_ms());
(is_newer && is_live).then_some(rotated)
}
async fn refresh_token( async fn refresh_token(
http: &reqwest::Client, http: &reqwest::Client,
user_agent: &str, user_agent: &str,
@ -1034,4 +1071,52 @@ mod tests {
assert_eq!(v["name"], "read"); assert_eq!(v["name"], "read");
assert_eq!(v["input_schema"]["type"], "object"); assert_eq!(v["input_schema"]["type"], "object");
} }
/// Write a credential file and return the `TokenState` naming it.
fn creds_at(dir: &std::path::Path, access: &str, expires_at_ms: u64) -> TokenState {
let path = dir.join(".credentials.json");
std::fs::write(
&path,
json!({
"claudeAiOauth": {
"accessToken": access,
"refreshToken": format!("refresh-for-{access}"),
"expiresAt": expires_at_ms,
}
})
.to_string(),
)
.unwrap();
load_credentials(&path).unwrap()
}
#[test]
fn rereads_credentials_rotated_by_another_process() {
let dir = tempfile::tempdir().unwrap();
let stale = creds_at(dir.path(), "spent", now_ms() + 60_000);
// `claude` wins the refresh race and rewrites the file underneath us.
creds_at(dir.path(), "rotated", now_ms() + 8 * 3_600_000);
let adopted = reread_credentials(&stale).expect("rotated token should be adopted");
assert_eq!(adopted.access_token.as_deref(), Some("rotated"));
// The matching refresh token comes with it — the spent one is dropped.
assert_eq!(
adopted.refresh_token.as_deref(),
Some("refresh-for-rotated")
);
}
#[test]
fn does_not_readopt_the_same_or_expired_token() {
let dir = tempfile::tempdir().unwrap();
// Unchanged file: nothing rotated, so there is nothing to adopt and the
// caller must surface the original refresh error.
let current = creds_at(dir.path(), "same", now_ms() + 60_000);
assert!(reread_credentials(&current).is_none());
// Rotated but already expired — adopting it would just fail differently.
let stale = creds_at(dir.path(), "spent", now_ms() + 60_000);
creds_at(dir.path(), "rotated-but-dead", now_ms().saturating_sub(1_000));
assert!(reread_credentials(&stale).is_none());
}
} }