claude: the credential file is the authority, not our startup copy
Logging out and into a second account rewrites the file with a token that is neither expired nor spent, so no refresh fires and nothing re-reads the disk — the provider keeps answering as the account that was left. Measured 2026-08-12: server up 10:55, file rewritten 15:20, 429s from 15:22 while the on-disk token returned 200 to the same request. Stat the file before each call and take it when it has moved. Write-back is now a compare-and-swap on the refresh token it started from, so our rotation can never overwrite a login made in between.
This commit is contained in:
parent
b56635e0f2
commit
f4837c52a8
1 changed files with 182 additions and 8 deletions
|
|
@ -117,6 +117,14 @@ struct TokenState {
|
|||
refresh_token: Option<String>,
|
||||
expires_at_ms: Option<u64>,
|
||||
source_path: Option<PathBuf>,
|
||||
source_stamp: Option<FileStamp>,
|
||||
}
|
||||
|
||||
/// Identity of the credential file as of the read that produced a `TokenState`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
struct FileStamp {
|
||||
mtime_ns: u64,
|
||||
len: u64,
|
||||
}
|
||||
|
||||
impl ClaudeSubscriptionProvider {
|
||||
|
|
@ -177,10 +185,11 @@ impl ClaudeSubscriptionProvider {
|
|||
.unwrap_or_default();
|
||||
|
||||
info!(
|
||||
"🔐 Claude subscription provider initialized — model: {}, cc_version: {}, creds: {}",
|
||||
"🔐 Claude subscription provider initialized — model: {}, cc_version: {}, creds: {} (cred {})",
|
||||
primary_model,
|
||||
cc_version,
|
||||
cred_path.display()
|
||||
cred_path.display(),
|
||||
credential_fingerprint(&token),
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
|
|
@ -249,6 +258,7 @@ impl ClaudeSubscriptionProvider {
|
|||
/// missing or inside the proactive window.
|
||||
async fn current_access_token(&self) -> Result<String> {
|
||||
let mut token = self.token.lock().await;
|
||||
adopt_file_if_changed(&mut token);
|
||||
let access_empty = token.access_token.as_deref().is_none_or(str::is_empty);
|
||||
let expiring = token
|
||||
.expires_at_ms
|
||||
|
|
@ -459,6 +469,69 @@ impl LlmProvider for ClaudeSubscriptionProvider {
|
|||
|
||||
// ── token refresh ==========================================================
|
||||
|
||||
/// Take the credential file whenever it has moved since we read it.
|
||||
///
|
||||
/// The file belongs to `claude`, not to us, and it is the only record of *which
|
||||
/// account* is logged in. A logout/login onto a second account rewrites it with
|
||||
/// a token that is neither expired nor spent, so nothing in the refresh path
|
||||
/// ever looks at the disk again and the provider keeps answering as the account
|
||||
/// the user has left — 429ing on its exhausted quota while a live token sits in
|
||||
/// the file. Measured 2026-08-12: server up since 10:55, file rewritten 15:20,
|
||||
/// 429s from 15:22 with the on-disk token returning 200 to the same request.
|
||||
fn adopt_file_if_changed(token: &mut TokenState) {
|
||||
let Some(path) = token.source_path.clone() else {
|
||||
return;
|
||||
};
|
||||
let stamp = stamp_of(&path);
|
||||
if stamp.is_none() || stamp == token.source_stamp {
|
||||
return;
|
||||
}
|
||||
let Ok(fresh) = load_credentials(&path) else {
|
||||
return; // mid-write, or logged out entirely — what we hold still answers
|
||||
};
|
||||
let rotated = fresh
|
||||
.access_token
|
||||
.as_deref()
|
||||
.is_some_and(|t| !t.is_empty() && Some(t) != token.access_token.as_deref());
|
||||
if !rotated {
|
||||
token.source_stamp = fresh.source_stamp;
|
||||
return;
|
||||
}
|
||||
info!(
|
||||
"claude credentials rewritten on disk; adopting cred {} (was {})",
|
||||
credential_fingerprint(&fresh),
|
||||
credential_fingerprint(token),
|
||||
);
|
||||
*token = fresh;
|
||||
}
|
||||
|
||||
fn stamp_of(path: &Path) -> Option<FileStamp> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
let mtime_ns = meta
|
||||
.modified()
|
||||
.ok()?
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()?
|
||||
.as_nanos() as u64;
|
||||
Some(FileStamp {
|
||||
mtime_ns,
|
||||
len: meta.len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Short, stable id for a credential — enough to see in a log that souveraine
|
||||
/// and `claude` are holding different logins. The token itself never gets there.
|
||||
fn credential_fingerprint(token: &TokenState) -> String {
|
||||
match token.access_token.as_deref().filter(|t| !t.is_empty()) {
|
||||
Some(access) => {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(access.as_bytes());
|
||||
hex::encode(hasher.finalize())[..8].to_string()
|
||||
}
|
||||
None => "none".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-read the credential file behind `current`, returning it only if it now
|
||||
/// holds a *different* and still-valid access token.
|
||||
///
|
||||
|
|
@ -525,10 +598,18 @@ async fn refresh_token(
|
|||
let new_expires = now_ms() + expires_in * 1000;
|
||||
|
||||
if let Some(path) = token.source_path.clone() {
|
||||
if let Err(e) =
|
||||
write_back_credentials(&path, &new_access, new_refresh.as_deref(), new_expires)
|
||||
{
|
||||
warn!("credential write-back failed (continuing in-memory): {e}");
|
||||
match write_back_credentials(
|
||||
&path,
|
||||
&new_access,
|
||||
new_refresh.as_deref(),
|
||||
new_expires,
|
||||
&refresh,
|
||||
) {
|
||||
Ok(true) => token.source_stamp = stamp_of(&path),
|
||||
// Leaving the stamp stale is the recovery: the next call sees the
|
||||
// file has moved and adopts whoever owns it now.
|
||||
Ok(false) => warn!("claude credential file holds another login; not writing over it"),
|
||||
Err(e) => warn!("credential write-back failed (continuing in-memory): {e}"),
|
||||
}
|
||||
}
|
||||
token.access_token = Some(new_access);
|
||||
|
|
@ -578,6 +659,10 @@ fn resolve_credential_path(configured: Option<&str>) -> Option<PathBuf> {
|
|||
|
||||
/// `{ claudeAiOauth: { accessToken, refreshToken, expiresAt(ms), ... }, ... }`
|
||||
fn load_credentials(path: &Path) -> Result<TokenState> {
|
||||
// Stamped before the read: a write landing between the two leaves us with a
|
||||
// stamp older than the file, so the next call re-reads rather than trusting
|
||||
// content it never saw.
|
||||
let stamp = stamp_of(path);
|
||||
let raw =
|
||||
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||
let value: Value = serde_json::from_str(&raw).context("parsing credentials json")?;
|
||||
|
|
@ -595,18 +680,33 @@ fn load_credentials(path: &Path) -> Result<TokenState> {
|
|||
.map(str::to_string),
|
||||
expires_at_ms: oauth.get("expiresAt").and_then(Value::as_u64),
|
||||
source_path: Some(path.to_path_buf()),
|
||||
source_stamp: stamp,
|
||||
})
|
||||
}
|
||||
|
||||
/// Atomically write refreshed tokens back, preserving all other fields.
|
||||
///
|
||||
/// Compare-and-swap on `refreshed_from`: the file is shared with `claude`, and
|
||||
/// between our refresh and this write the user may have logged into another
|
||||
/// account. Writing then replaces a live login with one from an account they
|
||||
/// left — breaking `claude` as well as us. Returns false when the file has
|
||||
/// moved on and was left alone.
|
||||
fn write_back_credentials(
|
||||
path: &Path,
|
||||
access: &str,
|
||||
new_refresh: Option<&str>,
|
||||
expires_at_ms: u64,
|
||||
) -> Result<()> {
|
||||
refreshed_from: &str,
|
||||
) -> Result<bool> {
|
||||
let raw = std::fs::read_to_string(path).unwrap_or_else(|_| "{}".to_string());
|
||||
let mut value: Value = serde_json::from_str(&raw).unwrap_or_else(|_| json!({}));
|
||||
let on_disk = value
|
||||
.get("claudeAiOauth")
|
||||
.and_then(|o| o.get("refreshToken"))
|
||||
.and_then(Value::as_str);
|
||||
if on_disk.is_some_and(|t| t != refreshed_from) {
|
||||
return Ok(false);
|
||||
}
|
||||
let oauth = value
|
||||
.as_object_mut()
|
||||
.context("cred file is not an object")?
|
||||
|
|
@ -630,7 +730,7 @@ fn write_back_credentials(
|
|||
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
// ── wire shaping ===========================================================
|
||||
|
|
@ -1504,6 +1604,80 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// The swap that started this: `claude logout` then a login on a second
|
||||
/// account. Nothing expired, nothing was spent, no refresh was attempted —
|
||||
/// and the token we hold belongs to an account the user has left.
|
||||
#[test]
|
||||
fn adopts_the_account_a_logout_login_wrote() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut held = creds_at(dir.path(), "account-a", now_ms() + 8 * 3_600_000);
|
||||
creds_at(dir.path(), "account-b", now_ms() + 8 * 3_600_000);
|
||||
|
||||
adopt_file_if_changed(&mut held);
|
||||
assert_eq!(held.access_token.as_deref(), Some("account-b"));
|
||||
assert_eq!(held.refresh_token.as_deref(), Some("refresh-for-account-b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_touched_but_unchanged_file_is_not_an_adoption() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut held = creds_at(dir.path(), "account-a", now_ms() + 8 * 3_600_000);
|
||||
let before = credential_fingerprint(&held);
|
||||
creds_at(dir.path(), "account-a", now_ms() + 8 * 3_600_000);
|
||||
|
||||
adopt_file_if_changed(&mut held);
|
||||
assert_eq!(credential_fingerprint(&held), before);
|
||||
// Stamp caught up, so the next call does not re-read the same file again.
|
||||
assert_eq!(
|
||||
held.source_stamp,
|
||||
stamp_of(&dir.path().join(".credentials.json"))
|
||||
);
|
||||
}
|
||||
|
||||
/// The other half of a swap: our refresh succeeds, but by the time it lands
|
||||
/// the file holds the account the user actually logged into. Writing would
|
||||
/// log `claude` back out from under them.
|
||||
#[test]
|
||||
fn write_back_refuses_to_overwrite_another_login() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(".credentials.json");
|
||||
creds_at(dir.path(), "account-b", now_ms() + 8 * 3_600_000);
|
||||
|
||||
let wrote = write_back_credentials(
|
||||
&path,
|
||||
"account-a-refreshed",
|
||||
Some("refresh-for-account-a-refreshed"),
|
||||
now_ms() + 8 * 3_600_000,
|
||||
"refresh-for-account-a",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!wrote);
|
||||
let after = load_credentials(&path).unwrap();
|
||||
assert_eq!(after.access_token.as_deref(), Some("account-b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_back_lands_when_the_file_is_still_ours() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(".credentials.json");
|
||||
creds_at(dir.path(), "account-a", now_ms() + 60_000);
|
||||
|
||||
let wrote = write_back_credentials(
|
||||
&path,
|
||||
"account-a-refreshed",
|
||||
Some("refresh-for-account-a-refreshed"),
|
||||
now_ms() + 8 * 3_600_000,
|
||||
"refresh-for-account-a",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(wrote);
|
||||
let after = load_credentials(&path).unwrap();
|
||||
assert_eq!(after.access_token.as_deref(), Some("account-a-refreshed"));
|
||||
// Everything else the file carries survives the write.
|
||||
let raw: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
assert!(raw["claudeAiOauth"]["expiresAt"].as_u64().unwrap() > now_ms());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_readopt_the_same_or_expired_token() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
Loading…
Reference in a new issue