Skip to main content

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}