use anyhow::{Context, Result};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClaudeOAuthCredentials {
pub access_token: String,
pub refresh_token: String,
pub expires_at: u64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KeychainData {
claude_ai_oauth: Option<ClaudeOAuthCredentials>,
}
#[derive(Debug)]
pub enum ApiCredential {
OAuth(String),
ApiKey(String),
}
pub fn read_claude_oauth() -> Result<Option<ClaudeOAuthCredentials>> {
let output = std::process::Command::new("security")
.args([
"find-generic-password",
"-s",
"Claude Code-credentials",
"-w",
])
.output()
.context("Failed to read from macOS Keychain")?;
if !output.status.success() {
return Ok(None);
}
let json_str = String::from_utf8(output.stdout).context("Keychain data is not valid UTF-8")?;
let data: KeychainData =
serde_json::from_str(json_str.trim()).context("Failed to parse keychain JSON")?;
Ok(data.claude_ai_oauth)
}
pub fn is_token_valid(creds: &ClaudeOAuthCredentials) -> bool {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
creds.expires_at > now_ms + 300_000
}
pub fn resolve_anthropic_credential() -> Result<ApiCredential> {
if let Ok(Some(creds)) = read_claude_oauth() {
if is_token_valid(&creds) {
return Ok(ApiCredential::OAuth(creds.access_token));
}
tracing::warn!("Claude Code OAuth token expired, falling back to API key");
}
if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") {
return Ok(ApiCredential::ApiKey(key));
}
anyhow::bail!(
"No Anthropic credentials available. Either:\n\
- Log in to Claude Code (`claude` CLI) for OAuth, or\n\
- Set ANTHROPIC_API_KEY environment variable"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_token_valid_future_expiry() {
let future_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64
+ 3_600_000;
let creds = ClaudeOAuthCredentials {
access_token: "test-token".to_string(),
refresh_token: "test-refresh".to_string(),
expires_at: future_ms,
};
assert!(is_token_valid(&creds));
}
#[test]
fn test_is_token_valid_past_expiry() {
let creds = ClaudeOAuthCredentials {
access_token: "test-token".to_string(),
refresh_token: "test-refresh".to_string(),
expires_at: 1000, };
assert!(!is_token_valid(&creds));
}
#[test]
fn test_is_token_valid_within_buffer() {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let creds = ClaudeOAuthCredentials {
access_token: "test-token".to_string(),
refresh_token: "test-refresh".to_string(),
expires_at: now_ms + 120_000, };
assert!(!is_token_valid(&creds));
}
#[test]
fn test_keychain_data_deserialization() {
let json = r#"{"claudeAiOauth": {"accessToken": "sk-ant-oat01-test", "refreshToken": "refresh-123", "expiresAt": 9999999999999}}"#;
let data: KeychainData = serde_json::from_str(json).unwrap();
let creds = data.claude_ai_oauth.unwrap();
assert_eq!(creds.access_token, "sk-ant-oat01-test");
assert_eq!(creds.refresh_token, "refresh-123");
assert_eq!(creds.expires_at, 9999999999999);
}
#[test]
fn test_keychain_data_missing_oauth() {
let json = r#"{}"#;
let data: KeychainData = serde_json::from_str(json).unwrap();
assert!(data.claude_ai_oauth.is_none());
}
}