use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::Deserialize;
use super::{CredentialError, CredentialResolver, ResolvedCredential};
#[derive(Debug, Clone)]
pub struct ClaudeCodeResolver {
enabled: bool,
}
impl ClaudeCodeResolver {
#[must_use]
pub const fn new() -> Self {
Self { enabled: true }
}
#[must_use]
pub const fn with_enabled(enabled: bool) -> Self {
Self { enabled }
}
}
impl Default for ClaudeCodeResolver {
fn default() -> Self {
Self::new()
}
}
#[derive(Deserialize)]
struct ClaudeCredentialsFile {
#[serde(rename = "claudeAiOauth")]
claude_ai_oauth: Option<ClaudeOAuthEntry>,
}
#[derive(Deserialize)]
struct ClaudeOAuthEntry {
#[serde(rename = "accessToken")]
access_token: Option<String>,
#[serde(rename = "expiresAt")]
expires_at: Option<u64>,
}
fn now_epoch_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as u64)
}
fn claude_credentials_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(|home| PathBuf::from(home).join(".claude"))
}
struct FileToken {
access_token: String,
expires_at: Option<u64>,
}
fn read_credentials_file() -> Option<FileToken> {
let dir = claude_credentials_dir()?;
let path = dir.join(".credentials.json");
let contents = fs::read_to_string(path).ok()?;
let parsed: ClaudeCredentialsFile = serde_json::from_str(&contents).ok()?;
let entry = parsed.claude_ai_oauth?;
let token = entry.access_token.filter(|t| !t.is_empty())?;
Some(FileToken {
access_token: token,
expires_at: entry.expires_at,
})
}
#[cfg(target_os = "macos")]
fn read_keychain() -> Option<String> {
let output = std::process::Command::new("security")
.args(["find-generic-password", "-s", "claude.ai", "-w"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let token = String::from_utf8(output.stdout).ok()?.trim().to_string();
if token.is_empty() {
return None;
}
Some(token)
}
#[cfg(not(target_os = "macos"))]
fn read_keychain() -> Option<String> {
None
}
impl CredentialResolver for ClaudeCodeResolver {
fn id(&self) -> &str {
"claude-code"
}
fn display_name(&self) -> &str {
"Claude Code (auto-discover)"
}
fn priority(&self) -> u16 {
300
}
fn resolve(&self) -> Result<Option<ResolvedCredential>, CredentialError> {
if !self.enabled {
return Ok(None);
}
if let Some(token) = read_keychain() {
return Ok(Some(ResolvedCredential::BearerToken(token)));
}
if let Some(file_token) = read_credentials_file() {
if let Some(expires_at) = file_token.expires_at {
if expires_at <= now_epoch_ms() {
eprintln!(
"\x1b[33mwarning\x1b[0m: Claude Code token expired. \
Run `claude login` to refresh it."
);
return Ok(None);
}
}
return Ok(Some(ResolvedCredential::BearerToken(
file_token.access_token,
)));
}
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
crate::test_env_lock()
}
fn temp_dir(label: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"claude-{label}-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
fn write_creds(tmp: &std::path::Path, json: &str) {
let claude_dir = tmp.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
fs::write(claude_dir.join(".credentials.json"), json).unwrap();
}
#[test]
fn disabled_resolver_returns_none() {
let resolver = ClaudeCodeResolver::with_enabled(false);
assert_eq!(resolver.resolve().unwrap(), None);
}
#[test]
fn returns_none_when_no_claude_dir() {
let _guard = env_lock();
let saved_home = std::env::var_os("HOME");
let saved_profile = std::env::var_os("USERPROFILE");
std::env::set_var("HOME", "/nonexistent-test-dir-12345");
std::env::remove_var("USERPROFILE");
let resolver = ClaudeCodeResolver::new();
assert_eq!(resolver.resolve().unwrap(), None);
if let Some(home) = saved_home {
std::env::set_var("HOME", home);
}
if let Some(profile) = saved_profile {
std::env::set_var("USERPROFILE", profile);
}
}
#[test]
fn reads_credentials_file() {
let _guard = env_lock();
let tmp = temp_dir("test");
write_creds(
&tmp,
r#"{"claudeAiOauth":{"accessToken":"test-token-123"}}"#,
);
let saved_home = std::env::var_os("HOME");
std::env::set_var("HOME", &tmp);
let resolver = ClaudeCodeResolver::new();
let cred = resolver.resolve().unwrap();
assert_eq!(
cred,
Some(ResolvedCredential::BearerToken("test-token-123".into()))
);
if let Some(home) = saved_home {
std::env::set_var("HOME", home);
}
let _ = fs::remove_dir_all(tmp);
}
#[test]
fn ignores_empty_token() {
let _guard = env_lock();
let tmp = temp_dir("empty");
write_creds(&tmp, r#"{"claudeAiOauth":{"accessToken":""}}"#);
let saved_home = std::env::var_os("HOME");
std::env::set_var("HOME", &tmp);
let resolver = ClaudeCodeResolver::new();
assert_eq!(resolver.resolve().unwrap(), None);
if let Some(home) = saved_home {
std::env::set_var("HOME", home);
}
let _ = fs::remove_dir_all(tmp);
}
#[test]
fn returns_token_when_not_expired() {
let _guard = env_lock();
let tmp = temp_dir("notexpired");
let future_ms = now_epoch_ms() + 3_600_000;
write_creds(
&tmp,
&format!(
r#"{{"claudeAiOauth":{{"accessToken":"fresh-tok","expiresAt":{future_ms}}}}}"#
),
);
let saved_home = std::env::var_os("HOME");
std::env::set_var("HOME", &tmp);
let resolver = ClaudeCodeResolver::new();
let cred = resolver.resolve().unwrap();
assert_eq!(
cred,
Some(ResolvedCredential::BearerToken("fresh-tok".into()))
);
if let Some(home) = saved_home {
std::env::set_var("HOME", home);
}
let _ = fs::remove_dir_all(tmp);
}
#[test]
fn returns_none_when_token_expired() {
let _guard = env_lock();
let tmp = temp_dir("expired");
write_creds(
&tmp,
r#"{"claudeAiOauth":{"accessToken":"old-tok","expiresAt":1000}}"#,
);
let saved_home = std::env::var_os("HOME");
std::env::set_var("HOME", &tmp);
let resolver = ClaudeCodeResolver::new();
assert_eq!(resolver.resolve().unwrap(), None);
if let Some(home) = saved_home {
std::env::set_var("HOME", home);
}
let _ = fs::remove_dir_all(tmp);
}
#[test]
fn returns_token_when_no_expiry_field() {
let _guard = env_lock();
let tmp = temp_dir("noexpiry");
write_creds(&tmp, r#"{"claudeAiOauth":{"accessToken":"no-expiry-tok"}}"#);
let saved_home = std::env::var_os("HOME");
std::env::set_var("HOME", &tmp);
let resolver = ClaudeCodeResolver::new();
let cred = resolver.resolve().unwrap();
assert_eq!(
cred,
Some(ResolvedCredential::BearerToken("no-expiry-tok".into()))
);
if let Some(home) = saved_home {
std::env::set_var("HOME", home);
}
let _ = fs::remove_dir_all(tmp);
}
#[test]
fn does_not_support_login() {
let resolver = ClaudeCodeResolver::new();
assert!(!resolver.supports_login());
}
#[test]
fn metadata() {
let resolver = ClaudeCodeResolver::new();
assert_eq!(resolver.id(), "claude-code");
assert_eq!(resolver.priority(), 300);
}
}