claude_usage/credentials/mod.rs
1//! Credential retrieval for Claude Code OAuth tokens.
2//!
3//! This module provides platform-specific credential retrieval:
4//! - macOS: Reads from Keychain
5//! - Linux: Reads from `~/.claude/.credentials.json`
6//!
7//! # Token Lifecycle
8//!
9//! Claude Code OAuth tokens have a limited validity period:
10//!
11//! | Property | Value |
12//! |----------|-------|
13//! | Token type | OAuth access token |
14//! | Validity | **8 hours** from issuance |
15//! | Refresh | Automatic by Claude Code CLI |
16//! | Storage | Platform keychain / credential file |
17//!
18//! ## Token Rotation
19//!
20//! Tokens are rotated approximately every 8 hours. When a token expires:
21//!
22//! 1. **If Claude Code is running**: It automatically refreshes the token using
23//! the refresh token stored alongside the access token.
24//!
25//! 2. **If Claude Code is not running**: The token will be expired when you
26//! next try to use it. Running `claude` will trigger a refresh.
27//!
28//! ## Error Handling for Expired Tokens
29//!
30//! This crate returns [`CredentialError::Expired`] when:
31//! - The `expiresAt` timestamp in the credential JSON is in the past
32//!
33//! The API returns [`ApiError::Unauthorized`](crate::ApiError::Unauthorized) when:
34//! - The token was valid locally but rejected by the server
35//! - This can happen if the token was revoked or if clocks are out of sync
36//!
37//! ## Recommended Error Handling
38//!
39//! ```rust,ignore
40//! use claude_usage::{get_usage, Error, CredentialError, ApiError};
41//!
42//! match get_usage() {
43//! Ok(usage) => { /* success */ }
44//! Err(Error::Credential(CredentialError::NotFound)) => {
45//! // User hasn't logged in with Claude Code
46//! eprintln!("Run `claude` to login first");
47//! }
48//! Err(Error::Credential(CredentialError::Expired)) => {
49//! // Token expired locally - needs refresh
50//! eprintln!("Token expired. Run `claude` to refresh");
51//! }
52//! Err(Error::Api(ApiError::Unauthorized)) => {
53//! // Token rejected by server - may be revoked or clock skew
54//! eprintln!("Token invalid. Run `claude` to re-authenticate");
55//! }
56//! Err(e) => eprintln!("Error: {}", e),
57//! }
58//! ```
59//!
60//! # Security
61//!
62//! Tokens are retrieved, used immediately, and discarded. They are never:
63//! - Logged
64//! - Stored in memory longer than necessary
65//! - Passed to other modules
66
67#[cfg(target_os = "macos")]
68mod macos;
69
70#[cfg(target_os = "linux")]
71mod linux;
72
73use crate::error::CredentialError;
74
75/// Service name used by Claude Code in macOS Keychain.
76pub const KEYCHAIN_SERVICE: &str = "Claude Code-credentials";
77
78/// Path to credentials file on Linux (relative to HOME).
79pub const LINUX_CREDENTIALS_PATH: &str = ".claude/.credentials.json";
80
81/// Environment variable that can override file-based credentials.
82pub const ENV_VAR_TOKEN: &str = "CLAUDE_CODE_OAUTH_TOKEN";
83
84/// Retrieve the OAuth access token from platform-specific storage.
85///
86/// On macOS, this reads from the Keychain.
87/// On Linux, this reads from `~/.claude/.credentials.json`.
88///
89/// The environment variable `CLAUDE_CODE_OAUTH_TOKEN` takes precedence
90/// on all platforms if set.
91///
92/// # Errors
93///
94/// Returns [`CredentialError`] if:
95/// - Credentials are not found
96/// - Credentials are expired
97/// - Credentials cannot be parsed
98/// - Required fields are missing
99pub fn get_token() -> Result<String, CredentialError> {
100 // Environment variable takes precedence on all platforms
101 if let Ok(token) = std::env::var(ENV_VAR_TOKEN) {
102 if !token.is_empty() {
103 return Ok(token);
104 }
105 }
106
107 #[cfg(target_os = "macos")]
108 {
109 macos::get_token_macos()
110 }
111
112 #[cfg(target_os = "linux")]
113 {
114 linux::get_token_linux()
115 }
116
117 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
118 {
119 Err(CredentialError::NotFound)
120 }
121}
122
123/// Parse credential JSON and extract the access token.
124///
125/// This function is shared between macOS and Linux implementations.
126///
127/// # Arguments
128///
129/// * `content` - The raw JSON content from Keychain or file
130///
131/// # Errors
132///
133/// Returns [`CredentialError`] if:
134/// - JSON parsing fails
135/// - `claudeAiOauth` field is missing
136/// - `accessToken` field is missing
137/// - Token is expired (based on `expiresAt`)
138pub(crate) fn parse_credential_json(content: &str) -> Result<String, CredentialError> {
139 let json: serde_json::Value =
140 serde_json::from_str(content).map_err(|e| CredentialError::Parse(e.to_string()))?;
141
142 let oauth = json
143 .get("claudeAiOauth")
144 .ok_or(CredentialError::MissingField("claudeAiOauth"))?;
145
146 // Check expiration if expiresAt is present (value is milliseconds since epoch)
147 if let Some(expires_at_ms) = oauth.get("expiresAt").and_then(|v| v.as_i64()) {
148 let now_ms = chrono::Utc::now().timestamp_millis();
149 if now_ms > expires_at_ms {
150 return Err(CredentialError::Expired);
151 }
152 }
153
154 let token = oauth
155 .get("accessToken")
156 .and_then(|v| v.as_str())
157 .ok_or(CredentialError::MissingField("accessToken"))?;
158
159 Ok(token.to_string())
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 #[test]
167 fn test_parse_valid_credentials() {
168 let json = r#"{
169 "claudeAiOauth": {
170 "accessToken": "sk-ant-oat01-test-token",
171 "refreshToken": "sk-ant-ort01-refresh",
172 "expiresAt": 9999999999999,
173 "scopes": ["user:inference", "user:profile"]
174 }
175 }"#;
176
177 let token = parse_credential_json(json).expect("should parse valid JSON");
178 assert_eq!(token, "sk-ant-oat01-test-token");
179 }
180
181 #[test]
182 fn test_parse_missing_claude_ai_oauth() {
183 let json = r#"{"other": "data"}"#;
184 let result = parse_credential_json(json);
185 assert!(matches!(
186 result,
187 Err(CredentialError::MissingField("claudeAiOauth"))
188 ));
189 }
190
191 #[test]
192 fn test_parse_missing_access_token() {
193 let json = r#"{
194 "claudeAiOauth": {
195 "refreshToken": "sk-ant-ort01-refresh"
196 }
197 }"#;
198 let result = parse_credential_json(json);
199 assert!(matches!(
200 result,
201 Err(CredentialError::MissingField("accessToken"))
202 ));
203 }
204
205 #[test]
206 fn test_parse_expired_token() {
207 let json = r#"{
208 "claudeAiOauth": {
209 "accessToken": "sk-ant-oat01-test-token",
210 "expiresAt": 1000
211 }
212 }"#;
213 let result = parse_credential_json(json);
214 assert!(matches!(result, Err(CredentialError::Expired)));
215 }
216
217 #[test]
218 fn test_parse_invalid_json() {
219 let json = "not valid json";
220 let result = parse_credential_json(json);
221 assert!(matches!(result, Err(CredentialError::Parse(_))));
222 }
223
224 #[test]
225 fn test_parse_no_expires_at_is_valid() {
226 // Credentials without expiresAt should still be valid
227 let json = r#"{
228 "claudeAiOauth": {
229 "accessToken": "sk-ant-oat01-no-expiry"
230 }
231 }"#;
232 let token = parse_credential_json(json).expect("should parse without expiresAt");
233 assert_eq!(token, "sk-ant-oat01-no-expiry");
234 }
235
236 // Env var tests are combined into one function to avoid parallel test interference
237 // since they modify the same environment variable (CLAUDE_CODE_OAUTH_TOKEN)
238 #[test]
239 fn test_env_var_behavior() {
240 use std::sync::Mutex;
241 static ENV_MUTEX: Mutex<()> = Mutex::new(());
242
243 // Lock to prevent parallel test interference
244 let _guard = ENV_MUTEX.lock().expect("env mutex");
245
246 // First: test that setting env var works
247 let token = "test-env-token-value";
248 std::env::set_var(ENV_VAR_TOKEN, token);
249 assert_eq!(std::env::var(ENV_VAR_TOKEN).ok(), Some(token.to_string()));
250
251 // Test that get_token returns the env var value
252 let result = get_token();
253 assert_eq!(result.expect("should use env var"), token);
254
255 // Second: test empty env var behavior
256 std::env::set_var(ENV_VAR_TOKEN, "");
257 let env_value = std::env::var(ENV_VAR_TOKEN).ok();
258 assert_eq!(env_value, Some(String::new()));
259
260 // Cleanup
261 std::env::remove_var(ENV_VAR_TOKEN);
262 }
263}