1use anyhow::{Context, Result};
7use serde::Deserialize;
8
9#[derive(Debug, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub struct ClaudeOAuthCredentials {
13 pub access_token: String,
14 pub refresh_token: String,
15 pub expires_at: u64,
16}
17
18#[derive(Debug, Deserialize)]
19#[serde(rename_all = "camelCase")]
20struct KeychainData {
21 claude_ai_oauth: Option<ClaudeOAuthCredentials>,
22}
23
24#[derive(Debug)]
26pub enum ApiCredential {
27 OAuth(String),
29 ApiKey(String),
31}
32
33pub fn read_claude_oauth() -> Result<Option<ClaudeOAuthCredentials>> {
38 let output = std::process::Command::new("security")
39 .args([
40 "find-generic-password",
41 "-s",
42 "Claude Code-credentials",
43 "-w",
44 ])
45 .output()
46 .context("Failed to read from macOS Keychain")?;
47
48 if !output.status.success() {
49 return Ok(None);
50 }
51
52 let json_str = String::from_utf8(output.stdout).context("Keychain data is not valid UTF-8")?;
53 let data: KeychainData =
54 serde_json::from_str(json_str.trim()).context("Failed to parse keychain JSON")?;
55
56 Ok(data.claude_ai_oauth)
57}
58
59pub fn is_token_valid(creds: &ClaudeOAuthCredentials) -> bool {
61 let now_ms = std::time::SystemTime::now()
62 .duration_since(std::time::UNIX_EPOCH)
63 .unwrap()
64 .as_millis() as u64;
65 creds.expires_at > now_ms + 300_000
66}
67
68pub fn resolve_anthropic_credential() -> Result<ApiCredential> {
72 if let Ok(Some(creds)) = read_claude_oauth() {
74 if is_token_valid(&creds) {
75 return Ok(ApiCredential::OAuth(creds.access_token));
76 }
77 tracing::warn!("Claude Code OAuth token expired, falling back to API key");
78 }
79
80 if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") {
82 return Ok(ApiCredential::ApiKey(key));
83 }
84
85 anyhow::bail!(
86 "No Anthropic credentials available. Either:\n\
87 - Log in to Claude Code (`claude` CLI) for OAuth, or\n\
88 - Set ANTHROPIC_API_KEY environment variable"
89 )
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn test_is_token_valid_future_expiry() {
98 let future_ms = std::time::SystemTime::now()
99 .duration_since(std::time::UNIX_EPOCH)
100 .unwrap()
101 .as_millis() as u64
102 + 3_600_000; let creds = ClaudeOAuthCredentials {
105 access_token: "test-token".to_string(),
106 refresh_token: "test-refresh".to_string(),
107 expires_at: future_ms,
108 };
109
110 assert!(is_token_valid(&creds));
111 }
112
113 #[test]
114 fn test_is_token_valid_past_expiry() {
115 let creds = ClaudeOAuthCredentials {
116 access_token: "test-token".to_string(),
117 refresh_token: "test-refresh".to_string(),
118 expires_at: 1000, };
120
121 assert!(!is_token_valid(&creds));
122 }
123
124 #[test]
125 fn test_is_token_valid_within_buffer() {
126 let now_ms = std::time::SystemTime::now()
128 .duration_since(std::time::UNIX_EPOCH)
129 .unwrap()
130 .as_millis() as u64;
131
132 let creds = ClaudeOAuthCredentials {
133 access_token: "test-token".to_string(),
134 refresh_token: "test-refresh".to_string(),
135 expires_at: now_ms + 120_000, };
137
138 assert!(!is_token_valid(&creds));
139 }
140
141 #[test]
142 fn test_keychain_data_deserialization() {
143 let json = r#"{"claudeAiOauth": {"accessToken": "sk-ant-oat01-test", "refreshToken": "refresh-123", "expiresAt": 9999999999999}}"#;
144 let data: KeychainData = serde_json::from_str(json).unwrap();
145 let creds = data.claude_ai_oauth.unwrap();
146 assert_eq!(creds.access_token, "sk-ant-oat01-test");
147 assert_eq!(creds.refresh_token, "refresh-123");
148 assert_eq!(creds.expires_at, 9999999999999);
149 }
150
151 #[test]
152 fn test_keychain_data_missing_oauth() {
153 let json = r#"{}"#;
154 let data: KeychainData = serde_json::from_str(json).unwrap();
155 assert!(data.claude_ai_oauth.is_none());
156 }
157}