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//! # Security
8//!
9//! Tokens are retrieved, used immediately, and discarded. They are never:
10//! - Logged
11//! - Stored in memory longer than necessary
12//! - Passed to other modules
13
14#[cfg(target_os = "macos")]
15mod macos;
16
17#[cfg(target_os = "linux")]
18mod linux;
19
20use crate::error::CredentialError;
21
22/// Service name used by Claude Code in macOS Keychain.
23pub const KEYCHAIN_SERVICE: &str = "Claude Code-credentials";
24
25/// Path to credentials file on Linux (relative to HOME).
26pub const LINUX_CREDENTIALS_PATH: &str = ".claude/.credentials.json";
27
28/// Environment variable that can override file-based credentials.
29pub const ENV_VAR_TOKEN: &str = "CLAUDE_CODE_OAUTH_TOKEN";
30
31/// Retrieve the OAuth access token from platform-specific storage.
32///
33/// On macOS, this reads from the Keychain.
34/// On Linux, this reads from `~/.claude/.credentials.json`.
35///
36/// The environment variable `CLAUDE_CODE_OAUTH_TOKEN` takes precedence
37/// on all platforms if set.
38///
39/// # Errors
40///
41/// Returns [`CredentialError`] if:
42/// - Credentials are not found
43/// - Credentials are expired
44/// - Credentials cannot be parsed
45/// - Required fields are missing
46pub fn get_token() -> Result<String, CredentialError> {
47    // Environment variable takes precedence on all platforms
48    if let Ok(token) = std::env::var(ENV_VAR_TOKEN) {
49        if !token.is_empty() {
50            return Ok(token);
51        }
52    }
53
54    #[cfg(target_os = "macos")]
55    {
56        macos::get_token_macos()
57    }
58
59    #[cfg(target_os = "linux")]
60    {
61        linux::get_token_linux()
62    }
63
64    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
65    {
66        Err(CredentialError::NotFound)
67    }
68}
69
70/// Parse credential JSON and extract the access token.
71///
72/// This function is shared between macOS and Linux implementations.
73///
74/// # Arguments
75///
76/// * `content` - The raw JSON content from Keychain or file
77///
78/// # Errors
79///
80/// Returns [`CredentialError`] if:
81/// - JSON parsing fails
82/// - `claudeAiOauth` field is missing
83/// - `accessToken` field is missing
84/// - Token is expired (based on `expiresAt`)
85pub(crate) fn parse_credential_json(content: &str) -> Result<String, CredentialError> {
86    let json: serde_json::Value =
87        serde_json::from_str(content).map_err(|e| CredentialError::Parse(e.to_string()))?;
88
89    let oauth = json
90        .get("claudeAiOauth")
91        .ok_or(CredentialError::MissingField("claudeAiOauth"))?;
92
93    // Check expiration if expiresAt is present
94    if let Some(expires_at) = oauth.get("expiresAt").and_then(|v| v.as_i64()) {
95        let now = std::time::SystemTime::now()
96            .duration_since(std::time::UNIX_EPOCH)
97            .expect("system time should be after Unix epoch")
98            .as_millis() as i64;
99
100        if now > expires_at {
101            return Err(CredentialError::Expired);
102        }
103    }
104
105    let token = oauth
106        .get("accessToken")
107        .and_then(|v| v.as_str())
108        .ok_or(CredentialError::MissingField("accessToken"))?;
109
110    Ok(token.to_string())
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn test_parse_valid_credentials() {
119        let json = r#"{
120            "claudeAiOauth": {
121                "accessToken": "sk-ant-oat01-test-token",
122                "refreshToken": "sk-ant-ort01-refresh",
123                "expiresAt": 9999999999999,
124                "scopes": ["user:inference", "user:profile"]
125            }
126        }"#;
127
128        let token = parse_credential_json(json).expect("should parse valid JSON");
129        assert_eq!(token, "sk-ant-oat01-test-token");
130    }
131
132    #[test]
133    fn test_parse_missing_claude_ai_oauth() {
134        let json = r#"{"other": "data"}"#;
135        let result = parse_credential_json(json);
136        assert!(matches!(
137            result,
138            Err(CredentialError::MissingField("claudeAiOauth"))
139        ));
140    }
141
142    #[test]
143    fn test_parse_missing_access_token() {
144        let json = r#"{
145            "claudeAiOauth": {
146                "refreshToken": "sk-ant-ort01-refresh"
147            }
148        }"#;
149        let result = parse_credential_json(json);
150        assert!(matches!(
151            result,
152            Err(CredentialError::MissingField("accessToken"))
153        ));
154    }
155
156    #[test]
157    fn test_parse_expired_token() {
158        let json = r#"{
159            "claudeAiOauth": {
160                "accessToken": "sk-ant-oat01-test-token",
161                "expiresAt": 1000
162            }
163        }"#;
164        let result = parse_credential_json(json);
165        assert!(matches!(result, Err(CredentialError::Expired)));
166    }
167
168    #[test]
169    fn test_parse_invalid_json() {
170        let json = "not valid json";
171        let result = parse_credential_json(json);
172        assert!(matches!(result, Err(CredentialError::Parse(_))));
173    }
174
175    #[test]
176    fn test_parse_no_expires_at_is_valid() {
177        // Credentials without expiresAt should still be valid
178        let json = r#"{
179            "claudeAiOauth": {
180                "accessToken": "sk-ant-oat01-no-expiry"
181            }
182        }"#;
183        let token = parse_credential_json(json).expect("should parse without expiresAt");
184        assert_eq!(token, "sk-ant-oat01-no-expiry");
185    }
186
187    #[test]
188    fn test_env_var_takes_precedence() {
189        // Use a unique env var name to avoid test interference
190        // Since we can't easily mock the env var check, test the logic directly
191        let token = "test-env-token-value";
192        std::env::set_var(ENV_VAR_TOKEN, token);
193
194        // Verify the env var is set
195        assert_eq!(std::env::var(ENV_VAR_TOKEN).ok(), Some(token.to_string()));
196
197        // The get_token function should return this value
198        let result = get_token();
199        std::env::remove_var(ENV_VAR_TOKEN);
200
201        assert_eq!(result.expect("should use env var"), token);
202    }
203
204    #[test]
205    fn test_empty_env_var_behavior() {
206        // Empty env var should fall through to platform-specific implementation
207        // We just verify the logic exists - actual behavior depends on platform state
208        std::env::set_var(ENV_VAR_TOKEN, "");
209
210        // Verify empty string is detected
211        let env_value = std::env::var(ENV_VAR_TOKEN).ok();
212        assert_eq!(env_value, Some(String::new()));
213
214        std::env::remove_var(ENV_VAR_TOKEN);
215    }
216}