1use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::cache::atomic_write;
14use crate::error::{AppError, Result};
15
16#[cfg(target_os = "macos")]
17use super::keychain;
18
19#[derive(Debug, Clone, Deserialize, Serialize)]
21pub struct CredentialsFile {
22 #[serde(rename = "claudeAiOauth")]
23 pub claude_ai_oauth: OauthCreds,
24}
25
26#[derive(Debug, Clone, Deserialize, Serialize)]
27pub struct OauthCreds {
28 #[serde(rename = "accessToken")]
29 pub access_token: String,
30 #[serde(rename = "refreshToken")]
31 pub refresh_token: String,
32 #[serde(rename = "expiresAt", deserialize_with = "de_ms_epoch")]
36 pub expires_at_ms: i64,
37 #[serde(rename = "subscriptionType", default)]
38 pub subscription_type: String,
39 #[serde(rename = "rateLimitTier", default)]
40 pub rate_limit_tier: String,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub scopes: Option<serde_json::Value>,
45}
46
47fn de_ms_epoch<'de, D>(d: D) -> std::result::Result<i64, D::Error>
48where
49 D: serde::Deserializer<'de>,
50{
51 let v = serde_json::Value::deserialize(d)?;
53 match v {
54 serde_json::Value::Number(n) => {
55 if let Some(i) = n.as_i64() {
56 Ok(i)
57 } else if let Some(f) = n.as_f64() {
58 Ok(f as i64)
59 } else {
60 Err(serde::de::Error::custom("expiresAt not numeric"))
61 }
62 }
63 _ => Err(serde::de::Error::custom("expiresAt must be a number")),
64 }
65}
66
67impl OauthCreds {
68 pub fn plan_label(&self) -> String {
71 let mut name = capitalize_first(&self.subscription_type);
72 if name.is_empty() {
73 name = "Unknown".into();
74 }
75 if self.rate_limit_tier.contains("5x") {
76 name.push_str(" 5x");
77 } else if self.rate_limit_tier.contains("20x") {
78 name.push_str(" 20x");
79 }
80 name
81 }
82
83 pub fn expires_at_secs(&self) -> i64 {
84 self.expires_at_ms / 1000
85 }
86}
87
88fn capitalize_first(s: &str) -> String {
89 let mut chars = s.chars();
90 match chars.next() {
91 Some(first) => {
92 let mut out = String::with_capacity(s.len());
93 for c in first.to_uppercase() {
94 out.push(c);
95 }
96 out.push_str(chars.as_str());
97 out
98 }
99 None => String::new(),
100 }
101}
102
103pub fn default_path() -> Result<PathBuf> {
109 Ok(crate::cache::home_dir()?
110 .join(".claude")
111 .join(".credentials.json"))
112}
113
114pub fn read_from(path: &Path) -> Result<CredentialsFile> {
119 match std::fs::read_to_string(path) {
120 Ok(raw) => parse(&raw, &path.display().to_string()),
121 Err(e) => Err(AppError::io_at(path, e)),
122 }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum CredsTarget {
131 Default(PathBuf),
134 Explicit(PathBuf),
137}
138
139impl CredsTarget {
140 pub fn path(&self) -> &Path {
141 match self {
142 CredsTarget::Default(p) | CredsTarget::Explicit(p) => p,
143 }
144 }
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum CredsSource {
152 File(PathBuf),
153 Keychain,
156}
157
158pub fn is_unusable(oauth: &OauthCreds) -> bool {
163 oauth.access_token.trim().is_empty()
164 && oauth.refresh_token.trim().is_empty()
165 && oauth.expires_at_ms <= 0
166}
167
168pub fn resolve(target: &CredsTarget) -> Result<(CredentialsFile, CredsSource)> {
172 match target {
173 CredsTarget::Explicit(p) => Ok((read_from(p)?, CredsSource::File(p.clone()))),
174 CredsTarget::Default(p) => {
175 #[cfg(target_os = "macos")]
176 return read_default_with(p, keychain::read_raw);
177 #[cfg(not(target_os = "macos"))]
178 read_default_with(p, || Ok(None))
179 }
180 }
181}
182
183fn read_default_with(
205 path: &Path,
206 keychain_read: impl Fn() -> Result<Option<String>>,
207) -> Result<(CredentialsFile, CredsSource)> {
208 let file_result = read_from(path);
209 match &file_result {
210 Ok(creds) if !is_unusable(&creds.claude_ai_oauth) => {
211 Ok((file_result?, CredsSource::File(path.to_path_buf())))
212 }
213 _ => match keychain_read()? {
215 Some(raw) => match parse(&raw, "macOS Keychain (Claude Code-credentials)") {
216 Ok(kc) if !is_unusable(&kc.claude_ai_oauth) => Ok((kc, CredsSource::Keychain)),
217 _ => Ok((file_result?, CredsSource::File(path.to_path_buf()))),
219 },
220 None => Ok((file_result?, CredsSource::File(path.to_path_buf()))),
221 },
222 }
223}
224
225fn parse(raw: &str, source: &str) -> Result<CredentialsFile> {
227 serde_json::from_str(raw).map_err(|e| {
228 AppError::Credentials(format!(
229 "could not parse {source}: {e}. Run `claude` to re-authenticate."
230 ))
231 })
232}
233
234fn merge_oauth(existing: Option<&str>, new_oauth: &OauthCreds) -> Result<serde_json::Value> {
239 let mut doc: serde_json::Value = existing
240 .and_then(|s| serde_json::from_str(s).ok())
241 .unwrap_or_else(|| serde_json::json!({}));
242 if !doc.is_object() {
243 doc = serde_json::json!({});
244 }
245 doc.as_object_mut().expect("just ensured object").insert(
246 "claudeAiOauth".into(),
247 serde_json::to_value(new_oauth).map_err(AppError::Json)?,
248 );
249 Ok(doc)
250}
251
252pub fn write_back_to(source: &CredsSource, new_oauth: &OauthCreds) -> Result<()> {
258 match source {
259 CredsSource::File(path) => write_back(path, new_oauth),
260 #[cfg(target_os = "macos")]
261 CredsSource::Keychain => {
262 let existing = keychain::read_raw()?;
263 let doc = merge_oauth(existing.as_deref(), new_oauth)?;
264 let json = serde_json::to_string(&doc).map_err(AppError::Json)?;
265 keychain::write_raw(&json)
266 }
267 #[cfg(not(target_os = "macos"))]
268 CredsSource::Keychain => Err(AppError::Other(
269 "Keychain credentials source is macOS-only".into(),
270 )),
271 }
272}
273
274pub fn write_back(path: &Path, new_oauth: &OauthCreds) -> Result<()> {
276 let existing = std::fs::read_to_string(path).ok();
277 let doc = merge_oauth(existing.as_deref(), new_oauth)?;
278 let bytes = serde_json::to_vec_pretty(&doc).map_err(AppError::Json)?;
279 atomic_write(path, &bytes)
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use std::io::Write;
286 use tempfile::{NamedTempFile, TempDir};
287
288 fn write_creds(s: &str) -> NamedTempFile {
289 let mut f = NamedTempFile::new().unwrap();
290 f.write_all(s.as_bytes()).unwrap();
291 f.flush().unwrap();
292 f
293 }
294
295 fn write_creds_closed(s: &str) -> (TempDir, std::path::PathBuf) {
299 crate::cache::closed_temp_file("credentials.json", Some(s))
300 }
301
302 #[test]
303 fn parses_canonical_shape() {
304 let f = write_creds(
305 r#"{"claudeAiOauth":{
306 "accessToken":"AT",
307 "refreshToken":"RT",
308 "expiresAt": 1735000000000,
309 "subscriptionType":"max",
310 "rateLimitTier":"default_claude_max_5x"
311 }}"#,
312 );
313 let creds = read_from(f.path()).unwrap();
314 assert_eq!(creds.claude_ai_oauth.access_token, "AT");
315 assert_eq!(creds.claude_ai_oauth.expires_at_ms, 1735000000000);
316 assert_eq!(creds.claude_ai_oauth.plan_label(), "Max 5x");
317 }
318
319 #[test]
320 fn accepts_float_expires_at() {
321 let f = write_creds(
323 r#"{"claudeAiOauth":{
324 "accessToken":"A","refreshToken":"R",
325 "expiresAt": 5000.0,
326 "subscriptionType":"pro","rateLimitTier":""
327 }}"#,
328 );
329 let creds = read_from(f.path()).unwrap();
330 assert_eq!(creds.claude_ai_oauth.expires_at_ms, 5000);
331 }
332
333 #[test]
334 fn plan_label_pro_no_tier() {
335 let f = write_creds(
336 r#"{"claudeAiOauth":{
337 "accessToken":"A","refreshToken":"R","expiresAt": 0,
338 "subscriptionType":"pro","rateLimitTier":""
339 }}"#,
340 );
341 let creds = read_from(f.path()).unwrap();
342 assert_eq!(creds.claude_ai_oauth.plan_label(), "Pro");
343 }
344
345 #[test]
346 fn plan_label_max_20x() {
347 let f = write_creds(
348 r#"{"claudeAiOauth":{
349 "accessToken":"A","refreshToken":"R","expiresAt": 0,
350 "subscriptionType":"max","rateLimitTier":"default_claude_max_20x"
351 }}"#,
352 );
353 let creds = read_from(f.path()).unwrap();
354 assert_eq!(creds.claude_ai_oauth.plan_label(), "Max 20x");
355 }
356
357 #[test]
358 fn plan_label_empty_subscription_falls_back() {
359 let f = write_creds(
360 r#"{"claudeAiOauth":{
361 "accessToken":"A","refreshToken":"R","expiresAt": 0,
362 "subscriptionType":"","rateLimitTier":""
363 }}"#,
364 );
365 let creds = read_from(f.path()).unwrap();
366 assert_eq!(creds.claude_ai_oauth.plan_label(), "Unknown");
367 }
368
369 #[test]
370 fn malformed_file_returns_credentials_error() {
371 let f = write_creds("not json");
372 let err = read_from(f.path()).unwrap_err();
373 assert!(matches!(err, AppError::Credentials(_)));
374 }
375
376 #[test]
381 fn read_from_missing_file_is_io_error() {
382 let path = std::path::Path::new("/nonexistent/ai-usagebar/.credentials.json");
383 let err = read_from(path).unwrap_err();
384 assert!(matches!(err, AppError::Io { .. }));
385 }
386
387 const USABLE: &str = r#"{"claudeAiOauth":{
392 "accessToken":"live-token","refreshToken":"rt","expiresAt": 9999999999999,
393 "subscriptionType":"max","rateLimitTier":""}}"#;
394 const UNUSABLE: &str = r#"{"claudeAiOauth":{
395 "accessToken":"","refreshToken":"","expiresAt": 0,
396 "subscriptionType":"","rateLimitTier":""}}"#;
397 const KEYCHAIN_USABLE: &str = r#"{"claudeAiOauth":{
398 "accessToken":"kc-token","refreshToken":"kc-rt","expiresAt": 9999999999999,
399 "subscriptionType":"max","rateLimitTier":""}}"#;
400
401 #[test]
402 fn is_unusable_only_when_fully_dead() {
403 let dead: CredentialsFile = serde_json::from_str(UNUSABLE).unwrap();
404 assert!(is_unusable(&dead.claude_ai_oauth));
405 let trusted: CredentialsFile = serde_json::from_str(
408 r#"{"claudeAiOauth":{"accessToken":"live","refreshToken":"",
409 "expiresAt": 9999999999999,"subscriptionType":"max","rateLimitTier":""}}"#,
410 )
411 .unwrap();
412 assert!(!is_unusable(&trusted.claude_ai_oauth));
413 }
414
415 #[test]
416 fn default_read_usable_file_wins_without_consulting_keychain() {
417 let (_dir, path) = write_creds_closed(USABLE);
418 let (creds, source) =
419 read_default_with(&path, || panic!("keychain must not be consulted")).unwrap();
420 assert_eq!(creds.claude_ai_oauth.access_token, "live-token");
421 assert_eq!(source, CredsSource::File(path));
422 }
423
424 #[test]
425 fn default_read_missing_file_falls_back_to_keychain() {
426 let dir = TempDir::new().unwrap();
427 let path = dir.path().join("missing.json");
428 let (creds, source) =
429 read_default_with(&path, || Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
430 assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
431 assert_eq!(source, CredsSource::Keychain);
432 }
433
434 #[test]
435 fn default_read_missing_file_without_keychain_is_io_error() {
436 let dir = TempDir::new().unwrap();
437 let path = dir.path().join("missing.json");
438 let err = read_default_with(&path, || Ok(None)).unwrap_err();
439 assert!(matches!(err, AppError::Io { .. }));
440 }
441
442 #[test]
443 fn a_locked_keychain_wins_over_the_file_missing_error() {
444 let dir = TempDir::new().unwrap();
449 let path = dir.path().join("missing.json");
450 let err = read_default_with(&path, || {
451 Err(AppError::Credentials("the Keychain is locked".into()))
452 })
453 .unwrap_err();
454 assert!(
455 matches!(err, AppError::Credentials(ref m) if m.contains("locked")),
456 "expected the Keychain error to surface, got {err:?}"
457 );
458 }
459
460 #[test]
461 fn default_read_unusable_file_prefers_usable_keychain() {
462 let (_dir, path) = write_creds_closed(UNUSABLE);
464 let (creds, source) =
465 read_default_with(&path, || Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
466 assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
467 assert_eq!(source, CredsSource::Keychain);
468 }
469
470 #[test]
471 fn default_read_unusable_file_kept_when_keychain_absent_or_dead() {
472 let (_dir, path) = write_creds_closed(UNUSABLE);
473 let (creds, source) = read_default_with(&path, || Ok(None)).unwrap();
475 assert!(is_unusable(&creds.claude_ai_oauth));
476 assert_eq!(source, CredsSource::File(path.clone()));
477 let (_, source) = read_default_with(&path, || Ok(Some(UNUSABLE.into()))).unwrap();
479 assert_eq!(source, CredsSource::File(path));
480 }
481
482 #[test]
483 fn default_read_unparsable_file_falls_back_to_keychain_else_errors() {
484 let (_dir, path) = write_creds_closed("not json at all");
485 let (creds, source) =
486 read_default_with(&path, || Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
487 assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
488 assert_eq!(source, CredsSource::Keychain);
489 let err = read_default_with(&path, || Ok(None)).unwrap_err();
491 assert!(matches!(err, AppError::Credentials(_)));
492 }
493
494 #[test]
495 fn resolve_explicit_never_falls_back() {
496 let dir = TempDir::new().unwrap();
500 let target = CredsTarget::Explicit(dir.path().join("missing.json"));
501 assert!(matches!(resolve(&target).unwrap_err(), AppError::Io { .. }));
502 }
503
504 #[test]
505 fn default_path_ends_with_claude_credentials() {
506 let p = default_path().unwrap();
507 assert!(p.ends_with(std::path::Path::new(".claude").join(".credentials.json")));
510 }
511
512 #[cfg(windows)]
515 #[test]
516 fn default_path_uses_userprofile_on_windows() {
517 let p = default_path().unwrap();
518 let userprofile = std::env::var("USERPROFILE").expect("USERPROFILE set on Windows");
519 let norm = |s: &str| s.to_lowercase().replace('/', "\\");
524 let p_norm = norm(&p.to_string_lossy());
525 let up_norm = norm(&userprofile);
526 assert!(
527 p_norm.starts_with(up_norm.as_str()),
528 "{} should live under {}",
529 p.display(),
530 userprofile
531 );
532 }
533
534 #[test]
535 fn merge_oauth_preserves_unknown_top_level_fields() {
536 let existing = r#"{"claudeAiOauth":{"accessToken":"OLD"},"mcpOAuth":{"x":1}}"#;
537 let new_oauth = OauthCreds {
538 access_token: "NEW".into(),
539 refresh_token: "RT".into(),
540 expires_at_ms: 99,
541 subscription_type: "max".into(),
542 rate_limit_tier: "".into(),
543 scopes: None,
544 };
545 let doc = merge_oauth(Some(existing), &new_oauth).unwrap();
546 assert_eq!(doc["mcpOAuth"]["x"], 1);
547 assert_eq!(doc["claudeAiOauth"]["accessToken"], "NEW");
548 assert_eq!(doc["claudeAiOauth"]["expiresAt"], 99);
549 }
550
551 #[test]
552 fn merge_oauth_handles_empty_and_non_object_input() {
553 let new_oauth = OauthCreds {
554 access_token: "A".into(),
555 refresh_token: "R".into(),
556 expires_at_ms: 0,
557 subscription_type: "pro".into(),
558 rate_limit_tier: "".into(),
559 scopes: None,
560 };
561 let doc = merge_oauth(None, &new_oauth).unwrap();
563 assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
564 let doc = merge_oauth(Some("not json"), &new_oauth).unwrap();
566 assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
567 let doc = merge_oauth(Some("[1,2,3]"), &new_oauth).unwrap();
568 assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
569 }
570
571 #[test]
572 fn write_back_round_trips_and_preserves_unknown_fields() {
573 let (_dir, path) = write_creds_closed(
574 r#"{"claudeAiOauth":{
575 "accessToken":"OLD","refreshToken":"OLD","expiresAt": 0,
576 "subscriptionType":"pro","rateLimitTier":""
577 },"someOtherField":"keep me"}"#,
578 );
579 let creds = read_from(&path).unwrap();
580 let new_oauth = OauthCreds {
581 access_token: "NEW".into(),
582 refresh_token: "NEW_RT".into(),
583 expires_at_ms: 1234,
584 subscription_type: "pro".into(),
585 rate_limit_tier: "".into(),
586 scopes: creds.claude_ai_oauth.scopes.clone(),
587 };
588 write_back(&path, &new_oauth).unwrap();
589 let raw = std::fs::read_to_string(&path).unwrap();
591 let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
592 assert_eq!(v["someOtherField"], "keep me");
593 assert_eq!(v["claudeAiOauth"]["accessToken"], "NEW");
594 assert_eq!(v["claudeAiOauth"]["expiresAt"], 1234);
595 }
596}