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:
parent
ca272d1d7d
commit
bc09480375
1 changed files with 86 additions and 1 deletions
|
|
@ -239,7 +239,21 @@ impl ClaudeSubscriptionProvider {
|
|||
.is_none_or(|exp| exp <= now_ms() + TOKEN_REFRESH_BUFFER.as_millis() as u64);
|
||||
if access_empty || expiring {
|
||||
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
|
||||
|
|
@ -390,6 +404,29 @@ impl LlmProvider for ClaudeSubscriptionProvider {
|
|||
|
||||
// ── 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(
|
||||
http: &reqwest::Client,
|
||||
user_agent: &str,
|
||||
|
|
@ -1034,4 +1071,52 @@ mod tests {
|
|||
assert_eq!(v["name"], "read");
|
||||
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(¤t).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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue