codineer_runtime/credentials/
claude_code.rs1use std::fs;
2use std::path::PathBuf;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use serde::Deserialize;
6
7use super::{CredentialError, CredentialResolver, ResolvedCredential};
8
9#[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
69struct 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 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}