claude_code_stats/source/
oauth.rs1use 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 if let Some(token) = try_keychain_token() {
35 return Ok(token);
36 }
37
38 if let Some(token) = try_credentials_file_token()? {
40 return Ok(token);
41 }
42
43 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 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 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 let _ = Command::new("claude").arg("--version").output();
133
134 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}