1pub mod oauth;
2
3use thiserror::Error;
4
5#[derive(Debug, Error)]
6pub enum AuthError {
7 #[error("No API key found: set ANTHROPIC_API_KEY or log in with `anthropic-auth login`")]
8 NoCredentials,
9 #[error("ANTHROPIC_API_KEY is empty")]
10 EmptyApiKey,
11 #[error("Failed to read keychain: {0}")]
12 KeychainError(String),
13 #[error("Failed to parse keychain credentials: {0}")]
14 ParseError(String),
15 #[error("OAuth error: {0}")]
16 OAuthError(String),
17}
18
19pub fn get_token() -> Result<String, AuthError> {
21 match std::env::var("ANTHROPIC_API_KEY") {
23 Ok(val) if val.is_empty() => return Err(AuthError::EmptyApiKey),
24 Ok(val) => return Ok(val),
25 Err(_) => {}
26 }
27
28 if let Ok(token) = get_keychain_token() {
30 return Ok(token);
31 }
32
33 if let Ok(creds) = oauth::load_credentials() {
35 if !creds.access_token.is_empty() {
36 return Ok(creds.access_token);
37 }
38 }
39
40 Err(AuthError::NoCredentials)
41}
42
43fn get_keychain_token() -> Result<String, AuthError> {
45 let user = std::env::var("USER").unwrap_or_default();
46 let output = std::process::Command::new("security")
47 .args(["find-generic-password", "-s", "Claude Code-credentials", "-a", &user, "-w"])
48 .output()
49 .map_err(|e| AuthError::KeychainError(e.to_string()))?;
50
51 if !output.status.success() {
52 return Err(AuthError::KeychainError("no credentials in keychain".into()));
53 }
54
55 let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
56 let json: serde_json::Value =
57 serde_json::from_str(&raw).map_err(|e| AuthError::ParseError(e.to_string()))?;
58
59 json.pointer("/claudeAiOauth/accessToken")
60 .and_then(|v| v.as_str())
61 .map(|s| s.to_string())
62 .ok_or_else(|| AuthError::ParseError("missing claudeAiOauth.accessToken".into()))
63}
64
65pub fn is_oauth_token(token: &str) -> bool {
66 token.starts_with("sk-ant-oat")
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72 use std::env;
73 use std::sync::Mutex;
74
75 static ENV_LOCK: Mutex<()> = Mutex::new(());
77
78 #[test]
79 fn get_token_returns_value_when_set() {
80 let _guard = ENV_LOCK.lock().unwrap();
81 env::set_var("ANTHROPIC_API_KEY", "sk-ant-test-key");
82 let result = get_token();
83 assert!(result.is_ok());
84 assert_eq!(result.unwrap(), "sk-ant-test-key");
85 }
86
87 #[test]
88 fn get_token_errors_when_empty() {
89 let _guard = ENV_LOCK.lock().unwrap();
90 env::set_var("ANTHROPIC_API_KEY", "");
91 let result = get_token();
92 assert!(result.is_err());
93 assert!(matches!(result.unwrap_err(), AuthError::EmptyApiKey));
94 }
95
96 #[test]
97 fn get_token_falls_back_when_env_missing() {
98 let _guard = ENV_LOCK.lock().unwrap();
99 env::remove_var("ANTHROPIC_API_KEY");
100 let result = get_token();
101 match result {
103 Ok(token) => assert!(!token.is_empty()),
104 Err(e) => assert!(matches!(e, AuthError::NoCredentials)),
105 }
106 }
107
108 #[test]
109 fn is_oauth_token_recognizes_oauth() {
110 assert!(is_oauth_token("sk-ant-oat-abc123"));
111 assert!(is_oauth_token("sk-ant-oat"));
112 }
113
114 #[test]
115 fn is_oauth_token_rejects_non_oauth() {
116 assert!(!is_oauth_token("sk-ant-api-key"));
117 assert!(!is_oauth_token(""));
118 assert!(!is_oauth_token("some-random-token"));
119 }
120}