use std::path::{Path, PathBuf};
use std::sync::Mutex;
static WRITES: Mutex<()> = Mutex::new(());
use serde::Deserialize;
use serde_json::{Value, json};
use crate::providers::import::{ImportError, ImportSource, Imported, home_dir, keychain};
use bitrouter_ai::auth::credentials::OAuthToken;
const KEYCHAIN_SERVICE: &str = "Claude Code-credentials";
const CLI_NAME: &str = "Claude Code";
#[derive(Deserialize)]
struct CredentialsFile {
#[serde(rename = "claudeAiOauth")]
claude_ai_oauth: Option<ClaudeAiOauth>,
}
#[derive(Deserialize)]
struct ClaudeAiOauth {
#[serde(rename = "accessToken")]
access_token: Option<String>,
#[serde(rename = "refreshToken")]
refresh_token: Option<String>,
#[serde(rename = "expiresAt")]
expires_at: Option<u64>,
}
pub fn import() -> Result<Option<Imported>, ImportError> {
if let Some(blob) = keychain::read_generic_password(KEYCHAIN_SERVICE, None)
&& let Ok(Some(token)) = parse_blob(&blob, "keychain")
{
return Ok(Some(Imported {
token,
source: ImportSource::Keychain(KEYCHAIN_SERVICE),
}));
}
let path = home_dir()
.ok_or(ImportError::NoHome)?
.join(".claude")
.join(".credentials.json");
from_file(&path)
}
pub fn from_file(path: &Path) -> Result<Option<Imported>, ImportError> {
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => {
return Err(ImportError::Io {
path: path.to_path_buf(),
source,
});
}
};
let blob = String::from_utf8_lossy(&bytes);
match parse_blob(blob.as_ref(), &path.display().to_string())? {
Some(token) => Ok(Some(Imported {
token,
source: ImportSource::File(path.to_path_buf()),
})),
None => Ok(None),
}
}
fn parse_blob(blob: &str, origin: &str) -> Result<Option<OAuthToken>, ImportError> {
let parsed: CredentialsFile =
serde_json::from_str(blob).map_err(|source| ImportError::Json {
origin: origin.to_string(),
source,
})?;
let Some(oauth) = parsed.claude_ai_oauth else {
return Ok(None);
};
let access_token = oauth
.access_token
.filter(|s| !s.is_empty())
.ok_or(ImportError::MissingAccessToken { cli: CLI_NAME })?;
let expires_at = oauth.expires_at.map(|ms| ms / 1000).unwrap_or(0);
Ok(Some(OAuthToken {
access_token,
expires_at,
refresh_token: oauth.refresh_token,
}))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClaudeCodeSource {
Keychain {
service: &'static str,
account: String,
},
File(PathBuf),
}
impl std::fmt::Display for ClaudeCodeSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ClaudeCodeSource::Keychain { service, .. } => {
write!(f, "macOS Keychain ({service})")
}
ClaudeCodeSource::File(path) => write!(f, "{}", path.display()),
}
}
}
#[derive(Debug, Clone)]
pub struct LiveCredential {
pub token: OAuthToken,
pub source: ClaudeCodeSource,
}
#[derive(Debug, Clone)]
pub struct ClaudeCodeStore {
keychain_service: Option<&'static str>,
file_path: PathBuf,
}
impl ClaudeCodeStore {
pub fn system() -> Option<Self> {
let file_path = home_dir()?.join(".claude").join(".credentials.json");
#[cfg(target_os = "macos")]
let keychain_service = Some(KEYCHAIN_SERVICE);
#[cfg(not(target_os = "macos"))]
let keychain_service = None;
Some(Self {
keychain_service,
file_path,
})
}
pub fn file_only(path: impl Into<PathBuf>) -> Self {
Self {
keychain_service: None,
file_path: path.into(),
}
}
pub(crate) fn coordination_identity(&self) -> (Option<&'static str>, PathBuf) {
if let Some(service) = self.keychain_service {
return (Some(service), PathBuf::new());
}
let path =
std::fs::canonicalize(&self.file_path).unwrap_or_else(|_| self.file_path.clone());
(None, path)
}
pub(crate) fn write_back_checked(
&self,
token: &OAuthToken,
original: &LiveCredential,
) -> std::result::Result<(), bitrouter_ai::auth::store::StoreError> {
use bitrouter_ai::auth::store::StoreError;
let _guard = WRITES.lock().map_err(|_| StoreError::Unavailable)?;
let current = self.read().map_err(|_| StoreError::Unavailable)?;
let Some(current) = current else {
return Err(StoreError::Conflict);
};
if current.source != original.source
|| (current.token != original.token && current.token != *token)
{
return Err(StoreError::Conflict);
}
self.write_back_locked(token, &original.source)
.map_err(|_| StoreError::Unavailable)
}
pub fn read(&self) -> Result<Option<LiveCredential>, ImportError> {
if let Some(service) = self.keychain_service
&& let Some(blob) = keychain::read_generic_password(service, None)
&& let Some(token) = parse_blob(&blob, "keychain")?
{
let account = keychain::find_account(service).ok_or_else(|| {
ImportError::Keyring(
"selected Claude Keychain account could not be resolved".into(),
)
})?;
return Ok(Some(LiveCredential {
token,
source: ClaudeCodeSource::Keychain { service, account },
}));
}
match from_file(&self.file_path)? {
Some(imported) => Ok(Some(LiveCredential {
token: imported.token,
source: ClaudeCodeSource::File(self.file_path.clone()),
})),
None => Ok(None),
}
}
pub fn write_back(
&self,
token: &OAuthToken,
source: &ClaudeCodeSource,
) -> Result<(), ImportError> {
let _guard = WRITES.lock().map_err(|_| ImportError::Io {
path: self.file_path.clone(),
source: std::io::Error::other("credential write lock unavailable"),
})?;
self.write_back_locked(token, source)
}
fn write_back_locked(
&self,
token: &OAuthToken,
source: &ClaudeCodeSource,
) -> Result<(), ImportError> {
match source {
ClaudeCodeSource::File(path) => {
let existing = std::fs::read(path).ok();
let bytes = updated_envelope_bytes(existing.as_deref(), token)?;
write_file_atomic_0600(path, &bytes)
}
ClaudeCodeSource::Keychain { service, account } => {
let existing = keychain::read_generic_password(service, Some(account));
let bytes = updated_envelope_bytes(existing.as_deref().map(str::as_bytes), token)?;
let value = String::from_utf8_lossy(&bytes).into_owned();
if keychain::write_generic_password(service, account, &value) {
Ok(())
} else {
Err(ImportError::Io {
path: PathBuf::from(format!("keychain:{service}")),
source: std::io::Error::other("security add-generic-password failed"),
})
}
}
}
}
}
fn updated_envelope_bytes(
existing: Option<&[u8]>,
token: &OAuthToken,
) -> Result<Vec<u8>, ImportError> {
let mut root: Value = match existing {
Some(b) if !b.is_empty() => serde_json::from_slice(b).unwrap_or_else(|_| json!({})),
_ => json!({}),
};
if !root.is_object() {
root = json!({});
}
let obj = root
.as_object_mut()
.ok_or(ImportError::MissingAccessToken { cli: CLI_NAME })?;
let entry = obj.entry("claudeAiOauth").or_insert_with(|| json!({}));
if !entry.is_object() {
*entry = json!({});
}
let oauth = entry
.as_object_mut()
.ok_or(ImportError::MissingAccessToken { cli: CLI_NAME })?;
oauth.insert("accessToken".to_string(), json!(token.access_token));
if let Some(rt) = &token.refresh_token {
oauth.insert("refreshToken".to_string(), json!(rt));
} else {
oauth.remove("refreshToken");
}
if token.expires_at > 0 {
oauth.insert(
"expiresAt".to_string(),
json!(token.expires_at.saturating_mul(1000)),
);
} else {
oauth.remove("expiresAt");
}
serde_json::to_vec_pretty(&root).map_err(|source| ImportError::Json {
origin: "claude code credentials write-back".to_string(),
source,
})
}
fn write_file_atomic_0600(path: &Path, bytes: &[u8]) -> Result<(), ImportError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|source| ImportError::Io {
path: parent.to_path_buf(),
source,
})?;
}
let mut tmp = path.as_os_str().to_owned();
tmp.push(".tmp");
let tmp = PathBuf::from(tmp);
#[cfg(unix)]
{
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt as _;
let _ = std::fs::remove_file(&tmp);
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp)
.map_err(|source| ImportError::Io {
path: tmp.clone(),
source,
})?;
file.write_all(bytes)
.and_then(|()| file.sync_all())
.map_err(|source| ImportError::Io {
path: tmp.clone(),
source,
})?;
}
#[cfg(not(unix))]
{
std::fs::write(&tmp, bytes).map_err(|source| ImportError::Io {
path: tmp.clone(),
source,
})?;
}
std::fs::rename(&tmp, path).map_err(|source| ImportError::Io {
path: path.to_path_buf(),
source,
})?;
#[cfg(unix)]
if let Some(parent) = path.parent() {
std::fs::File::open(parent)
.and_then(|file| file.sync_all())
.map_err(|source| ImportError::Io {
path: parent.to_path_buf(),
source,
})?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Context;
fn tmp_file(contents: &str) -> anyhow::Result<PathBuf> {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"bitrouter-import-claude-{}-{id}",
std::process::id()
));
std::fs::create_dir_all(&dir).context("required fixture value")?;
let path = dir.join(".credentials.json");
std::fs::write(&path, contents).context("required fixture value")?;
Ok(path)
}
#[test]
fn parses_oauth_and_converts_expiry_ms_to_secs() -> anyhow::Result<()> {
let path = tmp_file(
r#"{"claudeAiOauth":{"accessToken":"sk-ant-oat-1","refreshToken":"r1","expiresAt":1700000000000}}"#,
)?;
let imported = from_file(&path)
.context("required fixture value")?
.context("required fixture value")?;
assert_eq!(imported.token.access_token, "sk-ant-oat-1");
assert_eq!(imported.token.refresh_token.as_deref(), Some("r1"));
assert_eq!(imported.token.expires_at, 1_700_000_000);
assert!(matches!(imported.source, ImportSource::File(_)));
Ok(())
}
#[test]
fn missing_file_is_none() -> anyhow::Result<()> {
let path = std::env::temp_dir().join("bitrouter-import-claude-absent/never.json");
assert!(
from_file(&path)
.context("required fixture value")?
.is_none()
);
Ok(())
}
#[test]
fn no_oauth_block_is_none() -> anyhow::Result<()> {
let path = tmp_file(r#"{"somethingElse":true}"#)?;
assert!(
from_file(&path)
.context("required fixture value")?
.is_none()
);
Ok(())
}
#[test]
fn missing_access_token_errors() -> anyhow::Result<()> {
let path = tmp_file(r#"{"claudeAiOauth":{"refreshToken":"r1"}}"#)?;
let err = from_file(&path).err().context("expected fixture error")?;
assert!(matches!(err, ImportError::MissingAccessToken { .. }));
Ok(())
}
#[test]
fn store_read_file_only_returns_token_and_file_source() -> anyhow::Result<()> {
let path = tmp_file(
r#"{"claudeAiOauth":{"accessToken":"a","refreshToken":"r","expiresAt":1700000000000}}"#,
)?;
let store = ClaudeCodeStore::file_only(&path);
let live = store
.read()
.context("required fixture value")?
.context("required fixture value")?;
assert_eq!(live.token.access_token, "a");
assert_eq!(live.token.refresh_token.as_deref(), Some("r"));
assert_eq!(live.token.expires_at, 1_700_000_000);
match &live.source {
ClaudeCodeSource::File(p) => assert_eq!(p, &path),
other => anyhow::bail!("expected File source, got {other:?}"),
}
Ok(())
}
#[test]
fn store_read_file_only_absent_is_none() -> anyhow::Result<()> {
let path = std::env::temp_dir().join("bitrouter-ccstore-absent/never.json");
let store = ClaudeCodeStore::file_only(&path);
assert!(store.read().context("required fixture value")?.is_none());
Ok(())
}
#[test]
fn write_back_preserves_unknown_keys_and_updates_token() -> anyhow::Result<()> {
let path = tmp_file(
r#"{"claudeAiOauth":{"accessToken":"old","refreshToken":"oldr","expiresAt":1700000000000,"scopes":["user:inference"],"subscriptionType":"max"},"otherTop":42}"#,
)?;
let store = ClaudeCodeStore::file_only(&path);
store
.write_back(
&OAuthToken {
access_token: "new".into(),
expires_at: 1_700_000_500,
refresh_token: Some("newr".into()),
},
&ClaudeCodeSource::File(path.clone()),
)
.context("required fixture value")?;
let raw: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).context("required fixture value")?)
.context("required fixture value")?;
let o = &raw["claudeAiOauth"];
assert_eq!(o["accessToken"], "new");
assert_eq!(o["refreshToken"], "newr");
assert_eq!(o["expiresAt"], 1_700_000_500_000u64);
assert_eq!(o["subscriptionType"], "max");
assert_eq!(o["scopes"][0], "user:inference");
assert_eq!(raw["otherTop"], 42);
let live = store
.read()
.context("required fixture value")?
.context("required fixture value")?;
assert_eq!(live.token.access_token, "new");
assert_eq!(live.token.refresh_token.as_deref(), Some("newr"));
Ok(())
}
#[cfg(unix)]
#[test]
fn write_back_creates_fresh_file_0600() -> anyhow::Result<()> {
use std::os::unix::fs::PermissionsExt;
let path = tmp_file("{}")?;
std::fs::remove_file(&path).context("required fixture value")?;
let store = ClaudeCodeStore::file_only(&path);
store
.write_back(
&OAuthToken {
access_token: "a".into(),
expires_at: 1_700_000_000,
refresh_token: Some("r".into()),
},
&ClaudeCodeSource::File(path.clone()),
)
.context("required fixture value")?;
let mode = std::fs::metadata(&path)
.context("required fixture value")?
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
let live = store
.read()
.context("required fixture value")?
.context("required fixture value")?;
assert_eq!(live.token.access_token, "a");
Ok(())
}
}