use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
use async_trait::async_trait;
use bitrouter_ai::auth::credentials::Credential;
use bitrouter_ai::auth::store::{
CredentialKey, CredentialStore, CredentialTransaction, Durability, StoreError,
};
use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard};
use crate::providers::import::claude_code::{ClaudeCodeStore, LiveCredential};
use bitrouter_ai::auth::file::backend::FileCredentialStore;
use bitrouter_ai::auth::file::snapshot::CredentialStore as FileStore;
type LiveStores = HashMap<(Option<&'static str>, PathBuf), Arc<AsyncMutex<LiveState>>>;
static LIVE_STORES: OnceLock<Mutex<LiveStores>> = OnceLock::new();
#[derive(Default)]
struct LiveState {
pending: Option<Pending>,
}
struct Pending {
original: LiveCredential,
replacement: Credential,
}
pub struct ClaudeStore {
path: PathBuf,
marker: FileCredentialStore,
live: Option<ClaudeCodeStore>,
}
impl ClaudeStore {
pub fn new(path: PathBuf, live: Option<ClaudeCodeStore>) -> Result<Self, StoreError> {
let marker = FileCredentialStore::new(&path)?;
let path = marker.path().to_path_buf();
Ok(Self { marker, path, live })
}
}
struct LiveTransaction {
_marker: Box<dyn CredentialTransaction>,
path: PathBuf,
key: CredentialKey,
live: ClaudeCodeStore,
observed: Credential,
original: LiveCredential,
state: OwnedMutexGuard<LiveState>,
}
#[async_trait]
impl CredentialStore for ClaudeStore {
async fn begin(
&self,
key: &CredentialKey,
) -> Result<Box<dyn CredentialTransaction>, StoreError> {
let marker = self.marker.begin(key).await?;
if !matches!(marker.credential(), Some(Credential::ClaudeCodeCli)) {
return Ok(marker);
}
let Some(live) = self.live.as_ref() else {
return Ok(marker);
};
let gate = LIVE_STORES
.get_or_init(Mutex::default)
.lock()
.map_err(|_| StoreError::Unavailable)?
.entry(live.coordination_identity())
.or_default()
.clone();
let mut state = gate.lock_owned().await;
let current = live.read().map_err(|_| StoreError::Unavailable)?;
if state.pending.as_ref().is_some_and(|pending| {
current.as_ref().is_none_or(|current| {
current.source != pending.original.source
|| (current.token != pending.original.token
&& Some(&pending.replacement)
!= Some(&Credential::Oauth(current.token.clone())))
})
}) {
state.pending = None;
return Err(StoreError::Conflict);
}
let Some(current) = current else {
return Ok(marker);
};
Ok(Box::new(LiveTransaction {
_marker: marker,
path: self.path.clone(),
key: key.clone(),
live: live.clone(),
observed: Credential::Oauth(current.token.clone()),
original: current,
state,
}))
}
}
#[async_trait]
impl CredentialTransaction for LiveTransaction {
fn credential(&self) -> Option<&Credential> {
Some(&self.observed)
}
fn pending(&self) -> Option<&Credential> {
self.state.pending.as_ref().map(|p| &p.replacement)
}
fn stage(&mut self, credential: Credential) {
self.state.pending = Some(Pending {
original: self.original.clone(),
replacement: credential,
});
}
async fn commit(&mut self) -> Result<Durability, StoreError> {
let Some(pending) = self.state.pending.as_ref() else {
return Ok(Durability::Persistent);
};
let marker = FileStore::load(&self.path).map_err(|_| StoreError::Unavailable)?;
if marker.get_any(&self.key.provider, &self.key.account) != Some(&Credential::ClaudeCodeCli)
{
return Err(StoreError::Conflict);
}
let token = pending.replacement.as_oauth().ok_or(StoreError::Conflict)?;
match self.live.write_back_checked(token, &pending.original) {
Ok(()) => {
self.observed = pending.replacement.clone();
self.original.token = token.clone();
self.state.pending = None;
Ok(Durability::Persistent)
}
Err(StoreError::Conflict) => {
self.state.pending = None;
Err(StoreError::Conflict)
}
Err(error) => Err(error),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use bitrouter_ai::auth::credentials::OAuthToken;
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
fn key(account: &str) -> CredentialKey {
CredentialKey {
provider: "claude-code".into(),
account: account.into(),
}
}
fn replacement() -> Credential {
Credential::Oauth(OAuthToken {
access_token: "rotated-access".into(),
expires_at: 0,
refresh_token: Some("rotated-refresh".into()),
})
}
fn seed(path: &std::path::Path) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
std::fs::write(path, br#"{"claudeAiOauth":{"accessToken":"old","refreshToken":"old-refresh","expiresAt":1000,"scopes":["inference"]},"unrelated":42}"#)?;
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn retargeted_marker_alias_cannot_authorize_a_revoked_live_rotation() -> TestResult {
let directory = tempfile::tempdir()?;
let original = directory.path().join("original.json");
let other = directory.path().join("other.json");
let alias = directory.path().join("alias.json");
let cli = directory.path().join("cli.json");
seed(&cli)?;
FileStore::load(&original)?.set("claude-code", "first", Credential::ClaudeCodeCli)?;
FileStore::load(&other)?.set("claude-code", "first", Credential::ClaudeCodeCli)?;
std::os::unix::fs::symlink(&original, &alias)?;
let backend = ClaudeStore::new(alias.clone(), Some(ClaudeCodeStore::file_only(&cli)))?;
let mut transaction = backend.begin(&key("first")).await?;
transaction.stage(replacement());
FileStore::load(&original)?.remove("claude-code", "first")?;
std::fs::remove_file(&alias)?;
std::os::unix::fs::symlink(&other, &alias)?;
assert_eq!(transaction.commit().await, Err(StoreError::Conflict));
assert_eq!(
ClaudeCodeStore::file_only(&cli)
.read()?
.ok_or("missing CLI state")?
.token
.access_token,
"old"
);
Ok(())
}
#[tokio::test]
async fn live_commit_failure_retains_rotation_across_aliases_and_preserves_marker() -> TestResult
{
let directory = tempfile::tempdir()?;
let path = directory.path().join("adoptions.json");
let cli = directory.path().join("cli.json");
seed(&cli)?;
let mut file = FileStore::load(&path)?;
file.set("claude-code", "first", Credential::ClaudeCodeCli)?;
file.set("claude-code", "second", Credential::ClaudeCodeCli)?;
let backend = ClaudeStore::new(path.clone(), Some(ClaudeCodeStore::file_only(&cli)))?;
let mut transaction = backend.begin(&key("first")).await?;
transaction.stage(replacement());
let blocked = directory.path().join("cli.json.tmp");
std::fs::create_dir(&blocked)?;
assert_eq!(transaction.commit().await, Err(StoreError::Unavailable));
drop(transaction);
std::fs::remove_dir(&blocked)?;
let another = ClaudeStore::new(path.clone(), Some(ClaudeCodeStore::file_only(&cli)))?;
let mut transaction = another.begin(&key("second")).await?;
assert_eq!(transaction.pending(), Some(&replacement()));
assert_eq!(transaction.commit().await?, Durability::Persistent);
let stored = ClaudeCodeStore::file_only(&cli)
.read()?
.ok_or("missing CLI state")?;
assert_eq!(
stored.token,
replacement().as_oauth().ok_or("wrong token type")?.clone()
);
let body: serde_json::Value = serde_json::from_slice(&std::fs::read(&cli)?)?;
assert_eq!(body["unrelated"], 42);
assert_eq!(body["claudeAiOauth"]["scopes"][0], "inference");
assert_eq!(
FileStore::load(path)?.get_any("claude-code", "first"),
Some(&Credential::ClaudeCodeCli)
);
Ok(())
}
#[tokio::test]
async fn live_login_replacement_and_logout_reject_old_rotation() -> TestResult {
for deleted in [false, true] {
let directory = tempfile::tempdir()?;
let path = directory.path().join("adoptions.json");
let cli = directory.path().join("cli.json");
seed(&cli)?;
let mut file = FileStore::load(&path)?;
file.set("claude-code", "first", Credential::ClaudeCodeCli)?;
let backend = ClaudeStore::new(path, Some(ClaudeCodeStore::file_only(&cli)))?;
let mut transaction = backend.begin(&key("first")).await?;
transaction.stage(replacement());
if deleted {
std::fs::remove_file(&cli)?;
} else {
std::fs::write(&cli, br#"{"claudeAiOauth":{"accessToken":"new-login"}}"#)?;
}
assert_eq!(transaction.commit().await, Err(StoreError::Conflict));
assert!(transaction.pending().is_none());
if !deleted {
assert_eq!(
ClaudeCodeStore::file_only(&cli)
.read()?
.ok_or("missing login")?
.token
.access_token,
"new-login"
);
}
}
Ok(())
}
#[tokio::test]
async fn detached_alias_never_reappears_and_shared_cli_rotation_stays_pending() -> TestResult {
let directory = tempfile::tempdir()?;
let path = directory.path().join("adoptions.json");
let cli = directory.path().join("cli.json");
seed(&cli)?;
let mut file = FileStore::load(&path)?;
file.set("claude-code", "first", Credential::ClaudeCodeCli)?;
file.set("claude-code", "second", Credential::ClaudeCodeCli)?;
let backend = ClaudeStore::new(path.clone(), Some(ClaudeCodeStore::file_only(&cli)))?;
let mut transaction = backend.begin(&key("first")).await?;
transaction.stage(replacement());
file.remove("claude-code", "first")?;
assert_eq!(transaction.commit().await, Err(StoreError::Conflict));
assert!(transaction.pending().is_some());
drop(transaction);
let mut transaction = backend.begin(&key("second")).await?;
transaction.commit().await?;
assert!(
FileStore::load(path)?
.get_any("claude-code", "first")
.is_none()
);
assert_eq!(
ClaudeCodeStore::file_only(cli)
.read()?
.ok_or("missing CLI state")?
.token,
replacement().as_oauth().ok_or("wrong token")?.clone()
);
Ok(())
}
}