Watch
1
0
Fork
You've already forked souveraine
0

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:
Fimeg 2026-08-12 16:15:23 -04:00
commit f4837c52a8

View file

@ -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();