Skip to main content

codineer_runtime/credentials/
claude_code.rs

1use std::fs;
2use std::path::PathBuf;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use serde::Deserialize;
6
7use super::{CredentialError, CredentialResolver, ResolvedCredential};
8
9/// Auto-discovers credentials from an existing Claude Code installation.
10///
11/// Searches the following locations (in order):
12/// 1. macOS Keychain (`security find-generic-password -s "claude.ai" -w`)
13/// 2. `~/.claude/.credentials.json` (`claudeAiOauth.accessToken`)
14///
15/// Checks `expiresAt` in the credentials file and rejects expired tokens
16/// with a hint to run `claude login`.
17///
18/// This resolver does not support login/logout — Claude Code manages its own
19/// credentials. Users must run `claude login` in Claude Code first.
20#[derive(Debug, Clone)]
21pub struct ClaudeCodeResolver {
22    enabled: bool,
23}
24
25impl ClaudeCodeResolver {
26    #[must_use]
27    pub const fn new() -> Self {
28        Self { enabled: true }
29    }
30
31    #[must_use]
32    pub const fn with_enabled(enabled: bool) -> Self {
33        Self { enabled }
34    }
35}
36
37impl Default for ClaudeCodeResolver {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43#[derive(Deserialize)]
44struct ClaudeCredentialsFile {
45    #[serde(rename = "claudeAiOauth")]
46    claude_ai_oauth: Option<ClaudeOAuthEntry>,
47}
48
49#[derive(Deserialize)]
50struct ClaudeOAuthEntry {
51    #[serde(rename = "accessToken")]
52    access_token: Option<String>,
53    #[serde(rename = "expiresAt")]
54    expires_at: Option<u64>,
55}
56
57fn now_epoch_ms() -> u64 {
58    SystemTime::now()
59        .duration_since(UNIX_EPOCH)
60        .map_or(0, |d| d.as_millis() as u64)
61}
62
63fn claude_credentials_dir() -> Option<PathBuf> {
64    std::env::var_os("HOME")
65        .or_else(|| std::env::var_os("USERPROFILE"))
66        .map(|home| PathBuf::from(home).join(".claude"))
67}
68
69/// Token + optional expiry (millisecond epoch).
70struct FileToken {
71    access_token: String,
72    expires_at: Option<u64>,
73}
74
75fn read_credentials_file() -> Option<FileToken> {
76    let dir = claude_credentials_dir()?;
77    let path = dir.join(".credentials.json");
78    let contents = fs::read_to_string(path).ok()?;
79    let parsed: ClaudeCredentialsFile = serde_json::from_str(&contents).ok()?;
80    let entry = parsed.claude_ai_oauth?;
81    let token = entry.access_token.filter(|t| !t.is_empty())?;
82    Some(FileToken {
83        access_token: token,
84        expires_at: entry.expires_at,
85    })
86}
87
88#[cfg(target_os = "macos")]
89fn read_keychain() -> Option<String> {
90    let output = std::process::Command::new("security")
91        .args(["find-generic-password", "-s", "claude.ai", "-w"])
92        .output()
93        .ok()?;
94    if !output.status.success() {
95        return None;
96    }
97    let token = String::from_utf8(output.stdout).ok()?.trim().to_string();
98    if token.is_empty() {
99        return None;
100    }
101    Some(token)
102}
103
104#[cfg(not(target_os = "macos"))]
105fn read_keychain() -> Option<String> {
106    None
107}
108
109impl CredentialResolver for ClaudeCodeResolver {
110    fn id(&self) -> &str {
111        "claude-code"
112    }
113
114    fn display_name(&self) -> &str {
115        "Claude Code (auto-discover)"
116    }
117
118    fn priority(&self) -> u16 {
119        300
120    }
121
122    fn resolve(&self) -> Result<Option<ResolvedCredential>, CredentialError> {
123        if !self.enabled {
124            return Ok(None);
125        }
126
127        // Keychain tokens don't carry expiry metadata — trust the OS store.
128        if let Some(token) = read_keychain() {
129            return Ok(Some(ResolvedCredential::BearerToken(token)));
130        }
131
132        if let Some(file_token) = read_credentials_file() {
133            if let Some(expires_at) = file_token.expires_at {
134                if expires_at <= now_epoch_ms() {
135                    eprintln!(
136                        "\x1b[33mwarning\x1b[0m: Claude Code token expired. \
137                         Run `claude login` to refresh it."
138                    );
139                    return Ok(None);
140                }
141            }
142            return Ok(Some(ResolvedCredential::BearerToken(
143                file_token.access_token,
144            )));
145        }
146
147        Ok(None)
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
156        crate::test_env_lock()
157    }
158
159    fn temp_dir(label: &str) -> PathBuf {
160        std::env::temp_dir().join(format!(
161            "claude-{label}-{}",
162            SystemTime::now()
163                .duration_since(UNIX_EPOCH)
164                .unwrap()
165                .as_nanos()
166        ))
167    }
168
169    fn write_creds(tmp: &std::path::Path, json: &str) {
170        let claude_dir = tmp.join(".claude");
171        fs::create_dir_all(&claude_dir).unwrap();
172        fs::write(claude_dir.join(".credentials.json"), json).unwrap();
173    }
174
175    #[test]
176    fn disabled_resolver_returns_none() {
177        let resolver = ClaudeCodeResolver::with_enabled(false);
178        assert_eq!(resolver.resolve().unwrap(), None);
179    }
180
181    #[test]
182    fn returns_none_when_no_claude_dir() {
183        let _guard = env_lock();
184        let saved_home = std::env::var_os("HOME");
185        let saved_profile = std::env::var_os("USERPROFILE");
186
187        std::env::set_var("HOME", "/nonexistent-test-dir-12345");
188        std::env::remove_var("USERPROFILE");
189
190        let resolver = ClaudeCodeResolver::new();
191        assert_eq!(resolver.resolve().unwrap(), None);
192
193        if let Some(home) = saved_home {
194            std::env::set_var("HOME", home);
195        }
196        if let Some(profile) = saved_profile {
197            std::env::set_var("USERPROFILE", profile);
198        }
199    }
200
201    #[test]
202    fn reads_credentials_file() {
203        let _guard = env_lock();
204        let tmp = temp_dir("test");
205        write_creds(
206            &tmp,
207            r#"{"claudeAiOauth":{"accessToken":"test-token-123"}}"#,
208        );
209
210        let saved_home = std::env::var_os("HOME");
211        std::env::set_var("HOME", &tmp);
212
213        let resolver = ClaudeCodeResolver::new();
214        let cred = resolver.resolve().unwrap();
215        assert_eq!(
216            cred,
217            Some(ResolvedCredential::BearerToken("test-token-123".into()))
218        );
219
220        if let Some(home) = saved_home {
221            std::env::set_var("HOME", home);
222        }
223        let _ = fs::remove_dir_all(tmp);
224    }
225
226    #[test]
227    fn ignores_empty_token() {
228        let _guard = env_lock();
229        let tmp = temp_dir("empty");
230        write_creds(&tmp, r#"{"claudeAiOauth":{"accessToken":""}}"#);
231
232        let saved_home = std::env::var_os("HOME");
233        std::env::set_var("HOME", &tmp);
234
235        let resolver = ClaudeCodeResolver::new();
236        assert_eq!(resolver.resolve().unwrap(), None);
237
238        if let Some(home) = saved_home {
239            std::env::set_var("HOME", home);
240        }
241        let _ = fs::remove_dir_all(tmp);
242    }
243
244    #[test]
245    fn returns_token_when_not_expired() {
246        let _guard = env_lock();
247        let tmp = temp_dir("notexpired");
248        let future_ms = now_epoch_ms() + 3_600_000;
249        write_creds(
250            &tmp,
251            &format!(
252                r#"{{"claudeAiOauth":{{"accessToken":"fresh-tok","expiresAt":{future_ms}}}}}"#
253            ),
254        );
255
256        let saved_home = std::env::var_os("HOME");
257        std::env::set_var("HOME", &tmp);
258
259        let resolver = ClaudeCodeResolver::new();
260        let cred = resolver.resolve().unwrap();
261        assert_eq!(
262            cred,
263            Some(ResolvedCredential::BearerToken("fresh-tok".into()))
264        );
265
266        if let Some(home) = saved_home {
267            std::env::set_var("HOME", home);
268        }
269        let _ = fs::remove_dir_all(tmp);
270    }
271
272    #[test]
273    fn returns_none_when_token_expired() {
274        let _guard = env_lock();
275        let tmp = temp_dir("expired");
276        write_creds(
277            &tmp,
278            r#"{"claudeAiOauth":{"accessToken":"old-tok","expiresAt":1000}}"#,
279        );
280
281        let saved_home = std::env::var_os("HOME");
282        std::env::set_var("HOME", &tmp);
283
284        let resolver = ClaudeCodeResolver::new();
285        assert_eq!(resolver.resolve().unwrap(), None);
286
287        if let Some(home) = saved_home {
288            std::env::set_var("HOME", home);
289        }
290        let _ = fs::remove_dir_all(tmp);
291    }
292
293    #[test]
294    fn returns_token_when_no_expiry_field() {
295        let _guard = env_lock();
296        let tmp = temp_dir("noexpiry");
297        write_creds(&tmp, r#"{"claudeAiOauth":{"accessToken":"no-expiry-tok"}}"#);
298
299        let saved_home = std::env::var_os("HOME");
300        std::env::set_var("HOME", &tmp);
301
302        let resolver = ClaudeCodeResolver::new();
303        let cred = resolver.resolve().unwrap();
304        assert_eq!(
305            cred,
306            Some(ResolvedCredential::BearerToken("no-expiry-tok".into()))
307        );
308
309        if let Some(home) = saved_home {
310            std::env::set_var("HOME", home);
311        }
312        let _ = fs::remove_dir_all(tmp);
313    }
314
315    #[test]
316    fn does_not_support_login() {
317        let resolver = ClaudeCodeResolver::new();
318        assert!(!resolver.supports_login());
319    }
320
321    #[test]
322    fn metadata() {
323        let resolver = ClaudeCodeResolver::new();
324        assert_eq!(resolver.id(), "claude-code");
325        assert_eq!(resolver.priority(), 300);
326    }
327}