Skip to main content

rho_core/auth/
mod.rs

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
19/// Try ANTHROPIC_API_KEY env var, then macOS Keychain, then file-based OAuth credentials.
20pub fn get_token() -> Result<String, AuthError> {
21    // 1. Env var takes priority
22    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    // 2. Try macOS Keychain (Claude Code OAuth credentials)
29    if let Ok(token) = get_keychain_token() {
30        return Ok(token);
31    }
32
33    // 3. Try file-based OAuth credentials
34    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
43/// Read OAuth token from macOS Keychain where Claude Code stores credentials.
44fn 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    // Mutex to serialize env-var-dependent tests
76    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        // Will either find a keychain/file token or return NoCredentials
102        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}