Skip to main content

claude_code_stats/source/
oauth.rs

1use std::process::Command;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use anyhow::{Result, anyhow};
5
6use crate::source::SourceError;
7use crate::source::UsageSource;
8use crate::types::{ApiResponse, CredentialsFile, KeychainPayload};
9
10const USAGE_API_URL: &str = "https://api.anthropic.com/api/oauth/usage";
11
12const KEYCHAIN_SERVICE_NAMES: &[&str] = &[
13    "Claude Code-credentials",
14    "Claude Code-local-oauth-credentials",
15    "Claude Code",
16    "Claude Code-local-oauth",
17];
18
19pub struct OauthSource;
20
21impl UsageSource for OauthSource {
22    fn name(&self) -> &'static str {
23        "oauth_api"
24    }
25
26    fn try_fetch(&self) -> Result<ApiResponse, SourceError> {
27        let token = get_oauth_token().map_err(SourceError::Failed)?;
28        fetch_usage(&token).map_err(SourceError::Failed)
29    }
30}
31
32fn get_oauth_token() -> Result<String> {
33    // Try keychain first
34    if let Some(token) = try_keychain_token() {
35        return Ok(token);
36    }
37
38    // Try credentials file
39    if let Some(token) = try_credentials_file_token()? {
40        return Ok(token);
41    }
42
43    // Try delegated refresh via CLI
44    if let Some(token) = try_delegated_refresh()? {
45        return Ok(token);
46    }
47
48    Err(anyhow!(
49        "Could not find OAuth token via keychain, credentials file, or delegated refresh"
50    ))
51}
52
53fn try_keychain_token() -> Option<String> {
54    let user = std::env::var("USER").unwrap_or_default();
55    for service in KEYCHAIN_SERVICE_NAMES {
56        if let Ok(token) = read_keychain_entry(service, &user) {
57            return Some(token);
58        }
59    }
60    None
61}
62
63fn read_keychain_entry(service: &str, user: &str) -> Result<String> {
64    let output = Command::new("security")
65        .args(["find-generic-password", "-a", user, "-s", service, "-w"])
66        .output()?;
67
68    if !output.status.success() {
69        return Err(anyhow!("Keychain entry not found: {}", service));
70    }
71
72    let payload_str = String::from_utf8(output.stdout)?.trim().to_string();
73    let payload: KeychainPayload = serde_json::from_str(&payload_str)
74        .map_err(|e| anyhow!("Failed to parse keychain payload: {e}"))?;
75
76    payload
77        .claude_ai_oauth
78        .and_then(|oauth| oauth.access_token)
79        .ok_or_else(|| anyhow!("No access token in keychain payload"))
80}
81
82fn credentials_file_path() -> Option<std::path::PathBuf> {
83    dirs::home_dir().map(|h| h.join(".claude").join(".credentials.json"))
84}
85
86fn try_credentials_file_token() -> Result<Option<String>> {
87    let path = match credentials_file_path() {
88        Some(p) if p.exists() => p,
89        _ => return Ok(None),
90    };
91
92    let contents = std::fs::read_to_string(&path)?;
93    let creds: CredentialsFile = serde_json::from_str(&contents)
94        .map_err(|e| anyhow!("Failed to parse credentials file: {e}"))?;
95
96    let oauth = match creds.claude_ai_oauth {
97        Some(o) => o,
98        None => return Ok(None),
99    };
100
101    let token = match oauth.access_token {
102        Some(t) => t,
103        None => return Ok(None),
104    };
105
106    // Check expiry (expiresAt is milliseconds since epoch)
107    if let Some(expires_at_ms) = oauth.expires_at {
108        let now_ms = SystemTime::now()
109            .duration_since(UNIX_EPOCH)
110            .unwrap_or_default()
111            .as_millis() as i64;
112
113        if expires_at_ms <= now_ms {
114            eprintln!("claude-code-stats: credentials file token expired");
115            return Ok(None);
116        }
117    }
118
119    Ok(Some(token))
120}
121
122fn try_delegated_refresh() -> Result<Option<String>> {
123    // Check claude CLI exists
124    let which = Command::new("which").arg("claude").output()?;
125    if !which.status.success() {
126        return Ok(None);
127    }
128
129    eprintln!("claude-code-stats: attempting delegated token refresh via CLI");
130
131    // Run `claude --version` to trigger a potential token refresh
132    let _ = Command::new("claude").arg("--version").output();
133
134    // Re-read credentials file after potential refresh
135    try_credentials_file_token()
136}
137
138fn fetch_usage(token: &str) -> Result<ApiResponse> {
139    let client = reqwest::blocking::Client::new();
140
141    let response = client
142        .get(USAGE_API_URL)
143        .header("Authorization", format!("Bearer {token}"))
144        .header("Accept", "application/json, text/plain, */*")
145        .header("Content-Type", "application/json")
146        .header("anthropic-version", "2023-06-01")
147        .header(
148            "anthropic-beta",
149            "oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14",
150        )
151        .header("anthropic-dangerous-direct-browser-access", "true")
152        .timeout(std::time::Duration::from_secs(30))
153        .send()?;
154
155    let status = response.status();
156    let body = response.text()?;
157
158    if !status.is_success() {
159        return Err(anyhow!("API request failed with status {status}: {body}"));
160    }
161
162    let usage: ApiResponse = serde_json::from_str(&body)
163        .map_err(|err| anyhow!("error decoding response body: {err}; body: {body}"))?;
164    Ok(usage)
165}