Skip to main content

scud/llm/
oauth.rs

1//! OAuth token reader for Claude Code's macOS Keychain credentials.
2//!
3//! Reads OAuth tokens stored by Claude Code in the macOS Keychain,
4//! enabling direct Anthropic API calls using subscription billing (Pro/Max).
5
6use anyhow::{Context, Result};
7use serde::Deserialize;
8
9/// OAuth credentials stored by Claude Code
10#[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/// API credential type resolved from available sources
25#[derive(Debug)]
26pub enum ApiCredential {
27    /// Bearer token from Claude Code OAuth (subscription billing)
28    OAuth(String),
29    /// Standard x-api-key (API billing)
30    ApiKey(String),
31}
32
33/// Read OAuth credentials from Claude Code's macOS Keychain entry.
34///
35/// Returns `Ok(None)` if no credentials are stored (e.g. Claude Code
36/// not installed or user not logged in).
37pub 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
59/// Check if an OAuth token is still valid (with 5-minute buffer).
60pub 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
68/// Resolve the best available API credential for Anthropic.
69///
70/// Priority: OAuth token (subscription) > ANTHROPIC_API_KEY env var.
71pub fn resolve_anthropic_credential() -> Result<ApiCredential> {
72    // Try OAuth first
73    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    // Fall back to API key
81    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; // 1 hour from now
103
104        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, // Way in the past
119        };
120
121        assert!(!is_token_valid(&creds));
122    }
123
124    #[test]
125    fn test_is_token_valid_within_buffer() {
126        // Token expires in 2 minutes - within 5 minute buffer, should be invalid
127        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, // 2 minutes from now
136        };
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}