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 Named { path: PathBuf, config_dir: PathBuf },
146 Desktop(super::desktop_creds::DesktopCreds),
150}
151
152impl CredsTarget {
153 pub fn path(&self) -> &Path {
159 match self {
160 CredsTarget::Default(p) | CredsTarget::Explicit(p) => p,
161 CredsTarget::Named { path, .. } => path,
162 CredsTarget::Desktop(d) => d.blob_path(),
163 }
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
171pub enum CredsSource {
172 File(PathBuf),
173 Keychain,
176 NamedKeychain(PathBuf),
181 Desktop(super::desktop_creds::Writeback),
185}
186
187pub fn is_unusable(oauth: &OauthCreds) -> bool {
192 oauth.access_token.trim().is_empty()
193 && oauth.refresh_token.trim().is_empty()
194 && oauth.expires_at_ms <= 0
195}
196
197pub fn resolve(target: &CredsTarget) -> Result<(CredentialsFile, CredsSource)> {
201 match target {
202 CredsTarget::Explicit(p) => Ok((read_from(p)?, CredsSource::File(p.clone()))),
203 CredsTarget::Default(p) => {
204 #[cfg(target_os = "macos")]
205 return read_default_with(p, keychain::read_raw);
206 #[cfg(not(target_os = "macos"))]
207 read_default_with(p, || Ok(None))
208 }
209 CredsTarget::Named { path, config_dir } => {
210 #[cfg(target_os = "macos")]
211 return read_named_with(path, config_dir, keychain::read_raw_for);
212 #[cfg(not(target_os = "macos"))]
213 read_named_with(path, config_dir, |_| Ok(None))
214 }
215 CredsTarget::Desktop(desktop) => {
216 let (creds, writeback) = desktop.read()?;
217 Ok((creds, CredsSource::Desktop(writeback)))
218 }
219 }
220}
221
222fn read_default_with(
244 path: &Path,
245 keychain_read: impl Fn() -> Result<Option<String>>,
246) -> Result<(CredentialsFile, CredsSource)> {
247 let file_result = read_from(path);
248 match &file_result {
249 Ok(creds) if !is_unusable(&creds.claude_ai_oauth) => {
250 Ok((file_result?, CredsSource::File(path.to_path_buf())))
251 }
252 _ => match keychain_read()? {
254 Some(raw) => match parse(&raw, "macOS Keychain (Claude Code-credentials)") {
255 Ok(kc) if !is_unusable(&kc.claude_ai_oauth) => Ok((kc, CredsSource::Keychain)),
256 _ => Ok((file_result?, CredsSource::File(path.to_path_buf()))),
258 },
259 None => Ok((file_result?, CredsSource::File(path.to_path_buf()))),
260 },
261 }
262}
263
264fn read_named_with(
285 path: &Path,
286 config_dir: &Path,
287 keychain_read: impl Fn(&Path) -> Result<Option<String>>,
288) -> Result<(CredentialsFile, CredsSource)> {
289 if let Some(raw) = keychain_read(config_dir)?
292 && let Ok(kc) = parse(&raw, "macOS Keychain (named account)")
293 && !is_unusable(&kc.claude_ai_oauth)
294 {
295 return Ok((kc, CredsSource::NamedKeychain(config_dir.to_path_buf())));
296 }
297 Ok((read_from(path)?, CredsSource::File(path.to_path_buf())))
298}
299
300fn parse(raw: &str, source: &str) -> Result<CredentialsFile> {
302 serde_json::from_str(raw).map_err(|e| {
303 AppError::Credentials(format!(
304 "could not parse {source}: {e}. Run `claude` to re-authenticate."
305 ))
306 })
307}
308
309fn merge_oauth(existing: Option<&str>, new_oauth: &OauthCreds) -> Result<serde_json::Value> {
314 let mut doc: serde_json::Value = existing
315 .and_then(|s| serde_json::from_str(s).ok())
316 .unwrap_or_else(|| serde_json::json!({}));
317 if !doc.is_object() {
318 doc = serde_json::json!({});
319 }
320 doc.as_object_mut().expect("just ensured object").insert(
321 "claudeAiOauth".into(),
322 serde_json::to_value(new_oauth).map_err(AppError::Json)?,
323 );
324 Ok(doc)
325}
326
327pub fn write_back_to(source: &CredsSource, new_oauth: &OauthCreds) -> Result<()> {
333 match source {
334 CredsSource::File(path) => write_back(path, new_oauth),
335 #[cfg(target_os = "macos")]
336 CredsSource::Keychain => {
337 let existing = keychain::read_raw()?;
338 let doc = merge_oauth(existing.as_deref(), new_oauth)?;
339 let json = serde_json::to_string(&doc).map_err(AppError::Json)?;
340 keychain::write_raw(&json)
341 }
342 #[cfg(not(target_os = "macos"))]
343 CredsSource::Keychain => Err(AppError::Other(
344 "Keychain credentials source is macOS-only".into(),
345 )),
346 #[cfg(target_os = "macos")]
347 CredsSource::NamedKeychain(config_dir) => {
348 let existing = keychain::read_raw_for(config_dir)?;
349 let doc = merge_oauth(existing.as_deref(), new_oauth)?;
350 let json = serde_json::to_string(&doc).map_err(AppError::Json)?;
351 keychain::write_raw_for(config_dir, &json)
352 }
353 #[cfg(not(target_os = "macos"))]
354 CredsSource::NamedKeychain(_) => Err(AppError::Other(
355 "Keychain credentials source is macOS-only".into(),
356 )),
357 CredsSource::Desktop(writeback) => writeback.write(new_oauth),
358 }
359}
360
361pub fn write_back(path: &Path, new_oauth: &OauthCreds) -> Result<()> {
363 let existing = std::fs::read_to_string(path).ok();
364 let doc = merge_oauth(existing.as_deref(), new_oauth)?;
365 let bytes = serde_json::to_vec_pretty(&doc).map_err(AppError::Json)?;
366 atomic_write(path, &bytes)
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372 use std::io::Write;
373 use tempfile::{NamedTempFile, TempDir};
374
375 fn write_creds(s: &str) -> NamedTempFile {
376 let mut f = NamedTempFile::new().unwrap();
377 f.write_all(s.as_bytes()).unwrap();
378 f.flush().unwrap();
379 f
380 }
381
382 fn write_creds_closed(s: &str) -> (TempDir, std::path::PathBuf) {
386 crate::cache::closed_temp_file("credentials.json", Some(s))
387 }
388
389 #[test]
390 fn parses_canonical_shape() {
391 let f = write_creds(
392 r#"{"claudeAiOauth":{
393 "accessToken":"AT",
394 "refreshToken":"RT",
395 "expiresAt": 1735000000000,
396 "subscriptionType":"max",
397 "rateLimitTier":"default_claude_max_5x"
398 }}"#,
399 );
400 let creds = read_from(f.path()).unwrap();
401 assert_eq!(creds.claude_ai_oauth.access_token, "AT");
402 assert_eq!(creds.claude_ai_oauth.expires_at_ms, 1735000000000);
403 assert_eq!(creds.claude_ai_oauth.plan_label(), "Max 5x");
404 }
405
406 #[test]
407 fn accepts_float_expires_at() {
408 let f = write_creds(
410 r#"{"claudeAiOauth":{
411 "accessToken":"A","refreshToken":"R",
412 "expiresAt": 5000.0,
413 "subscriptionType":"pro","rateLimitTier":""
414 }}"#,
415 );
416 let creds = read_from(f.path()).unwrap();
417 assert_eq!(creds.claude_ai_oauth.expires_at_ms, 5000);
418 }
419
420 #[test]
421 fn plan_label_pro_no_tier() {
422 let f = write_creds(
423 r#"{"claudeAiOauth":{
424 "accessToken":"A","refreshToken":"R","expiresAt": 0,
425 "subscriptionType":"pro","rateLimitTier":""
426 }}"#,
427 );
428 let creds = read_from(f.path()).unwrap();
429 assert_eq!(creds.claude_ai_oauth.plan_label(), "Pro");
430 }
431
432 #[test]
433 fn plan_label_max_20x() {
434 let f = write_creds(
435 r#"{"claudeAiOauth":{
436 "accessToken":"A","refreshToken":"R","expiresAt": 0,
437 "subscriptionType":"max","rateLimitTier":"default_claude_max_20x"
438 }}"#,
439 );
440 let creds = read_from(f.path()).unwrap();
441 assert_eq!(creds.claude_ai_oauth.plan_label(), "Max 20x");
442 }
443
444 #[test]
445 fn plan_label_empty_subscription_falls_back() {
446 let f = write_creds(
447 r#"{"claudeAiOauth":{
448 "accessToken":"A","refreshToken":"R","expiresAt": 0,
449 "subscriptionType":"","rateLimitTier":""
450 }}"#,
451 );
452 let creds = read_from(f.path()).unwrap();
453 assert_eq!(creds.claude_ai_oauth.plan_label(), "Unknown");
454 }
455
456 #[test]
457 fn malformed_file_returns_credentials_error() {
458 let f = write_creds("not json");
459 let err = read_from(f.path()).unwrap_err();
460 assert!(matches!(err, AppError::Credentials(_)));
461 }
462
463 #[test]
468 fn read_from_missing_file_is_io_error() {
469 let path = std::path::Path::new("/nonexistent/ai-usagebar/.credentials.json");
470 let err = read_from(path).unwrap_err();
471 assert!(matches!(err, AppError::Io { .. }));
472 }
473
474 const USABLE: &str = r#"{"claudeAiOauth":{
479 "accessToken":"live-token","refreshToken":"rt","expiresAt": 9999999999999,
480 "subscriptionType":"max","rateLimitTier":""}}"#;
481 const UNUSABLE: &str = r#"{"claudeAiOauth":{
482 "accessToken":"","refreshToken":"","expiresAt": 0,
483 "subscriptionType":"","rateLimitTier":""}}"#;
484 const KEYCHAIN_USABLE: &str = r#"{"claudeAiOauth":{
485 "accessToken":"kc-token","refreshToken":"kc-rt","expiresAt": 9999999999999,
486 "subscriptionType":"max","rateLimitTier":""}}"#;
487
488 #[test]
489 fn is_unusable_only_when_fully_dead() {
490 let dead: CredentialsFile = serde_json::from_str(UNUSABLE).unwrap();
491 assert!(is_unusable(&dead.claude_ai_oauth));
492 let trusted: CredentialsFile = serde_json::from_str(
495 r#"{"claudeAiOauth":{"accessToken":"live","refreshToken":"",
496 "expiresAt": 9999999999999,"subscriptionType":"max","rateLimitTier":""}}"#,
497 )
498 .unwrap();
499 assert!(!is_unusable(&trusted.claude_ai_oauth));
500 }
501
502 #[test]
503 fn default_read_usable_file_wins_without_consulting_keychain() {
504 let (_dir, path) = write_creds_closed(USABLE);
505 let (creds, source) =
506 read_default_with(&path, || panic!("keychain must not be consulted")).unwrap();
507 assert_eq!(creds.claude_ai_oauth.access_token, "live-token");
508 assert_eq!(source, CredsSource::File(path));
509 }
510
511 #[test]
512 fn default_read_missing_file_falls_back_to_keychain() {
513 let dir = TempDir::new().unwrap();
514 let path = dir.path().join("missing.json");
515 let (creds, source) =
516 read_default_with(&path, || Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
517 assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
518 assert_eq!(source, CredsSource::Keychain);
519 }
520
521 #[test]
522 fn default_read_missing_file_without_keychain_is_io_error() {
523 let dir = TempDir::new().unwrap();
524 let path = dir.path().join("missing.json");
525 let err = read_default_with(&path, || Ok(None)).unwrap_err();
526 assert!(matches!(err, AppError::Io { .. }));
527 }
528
529 #[test]
530 fn a_locked_keychain_wins_over_the_file_missing_error() {
531 let dir = TempDir::new().unwrap();
536 let path = dir.path().join("missing.json");
537 let err = read_default_with(&path, || {
538 Err(AppError::Credentials("the Keychain is locked".into()))
539 })
540 .unwrap_err();
541 assert!(
542 matches!(err, AppError::Credentials(ref m) if m.contains("locked")),
543 "expected the Keychain error to surface, got {err:?}"
544 );
545 }
546
547 #[test]
548 fn default_read_unusable_file_prefers_usable_keychain() {
549 let (_dir, path) = write_creds_closed(UNUSABLE);
551 let (creds, source) =
552 read_default_with(&path, || Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
553 assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
554 assert_eq!(source, CredsSource::Keychain);
555 }
556
557 #[test]
558 fn default_read_unusable_file_kept_when_keychain_absent_or_dead() {
559 let (_dir, path) = write_creds_closed(UNUSABLE);
560 let (creds, source) = read_default_with(&path, || Ok(None)).unwrap();
562 assert!(is_unusable(&creds.claude_ai_oauth));
563 assert_eq!(source, CredsSource::File(path.clone()));
564 let (_, source) = read_default_with(&path, || Ok(Some(UNUSABLE.into()))).unwrap();
566 assert_eq!(source, CredsSource::File(path));
567 }
568
569 #[test]
570 fn default_read_unparsable_file_falls_back_to_keychain_else_errors() {
571 let (_dir, path) = write_creds_closed("not json at all");
572 let (creds, source) =
573 read_default_with(&path, || Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
574 assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
575 assert_eq!(source, CredsSource::Keychain);
576 let err = read_default_with(&path, || Ok(None)).unwrap_err();
578 assert!(matches!(err, AppError::Credentials(_)));
579 }
580
581 #[test]
586 fn named_read_prefers_keychain_over_a_live_looking_file() {
587 let (_dir, path) = write_creds_closed(USABLE);
592 let cfg_dir = path.parent().unwrap().to_path_buf();
593 let (creds, source) =
594 read_named_with(&path, &cfg_dir, |_| Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
595 assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
596 assert_eq!(source, CredsSource::NamedKeychain(cfg_dir));
597 }
598
599 #[test]
600 fn named_read_falls_back_to_file_when_keychain_absent() {
601 let (_dir, path) = write_creds_closed(USABLE);
603 let cfg_dir = path.parent().unwrap().to_path_buf();
604 let (creds, source) = read_named_with(&path, &cfg_dir, |_| Ok(None)).unwrap();
605 assert_eq!(creds.claude_ai_oauth.access_token, "live-token");
606 assert_eq!(source, CredsSource::File(path));
607 }
608
609 #[test]
610 fn named_read_unusable_keychain_falls_back_to_file() {
611 let (_dir, path) = write_creds_closed(USABLE);
612 let cfg_dir = path.parent().unwrap().to_path_buf();
613 let (_, source) = read_named_with(&path, &cfg_dir, |_| Ok(Some(UNUSABLE.into()))).unwrap();
614 assert_eq!(source, CredsSource::File(path.clone()));
615 let (_, source) =
617 read_named_with(&path, &cfg_dir, |_| Ok(Some("not json".into()))).unwrap();
618 assert_eq!(source, CredsSource::File(path));
619 }
620
621 #[test]
622 fn named_read_both_missing_surfaces_the_file_error() {
623 let dir = TempDir::new().unwrap();
624 let path = dir.path().join("missing.json");
625 let err = read_named_with(&path, dir.path(), |_| Ok(None)).unwrap_err();
626 assert!(matches!(err, AppError::Io { .. }));
627 }
628
629 #[test]
630 fn named_read_locked_keychain_error_surfaces_not_the_stale_file() {
631 let (_dir, path) = write_creds_closed(USABLE);
635 let cfg_dir = path.parent().unwrap();
636 let err = read_named_with(&path, cfg_dir, |_| {
637 Err(AppError::Credentials("the Keychain is locked".into()))
638 })
639 .unwrap_err();
640 assert!(matches!(err, AppError::Credentials(ref m) if m.contains("locked")));
641 }
642
643 #[test]
644 fn resolve_explicit_never_falls_back() {
645 let dir = TempDir::new().unwrap();
649 let target = CredsTarget::Explicit(dir.path().join("missing.json"));
650 assert!(matches!(resolve(&target).unwrap_err(), AppError::Io { .. }));
651 }
652
653 #[test]
654 fn default_path_ends_with_claude_credentials() {
655 let p = default_path().unwrap();
656 assert!(p.ends_with(std::path::Path::new(".claude").join(".credentials.json")));
659 }
660
661 #[cfg(windows)]
664 #[test]
665 fn default_path_uses_userprofile_on_windows() {
666 let p = default_path().unwrap();
667 let userprofile = std::env::var("USERPROFILE").expect("USERPROFILE set on Windows");
668 let norm = |s: &str| s.to_lowercase().replace('/', "\\");
673 let p_norm = norm(&p.to_string_lossy());
674 let up_norm = norm(&userprofile);
675 assert!(
676 p_norm.starts_with(up_norm.as_str()),
677 "{} should live under {}",
678 p.display(),
679 userprofile
680 );
681 }
682
683 #[test]
684 fn merge_oauth_preserves_unknown_top_level_fields() {
685 let existing = r#"{"claudeAiOauth":{"accessToken":"OLD"},"mcpOAuth":{"x":1}}"#;
686 let new_oauth = OauthCreds {
687 access_token: "NEW".into(),
688 refresh_token: "RT".into(),
689 expires_at_ms: 99,
690 subscription_type: "max".into(),
691 rate_limit_tier: "".into(),
692 scopes: None,
693 };
694 let doc = merge_oauth(Some(existing), &new_oauth).unwrap();
695 assert_eq!(doc["mcpOAuth"]["x"], 1);
696 assert_eq!(doc["claudeAiOauth"]["accessToken"], "NEW");
697 assert_eq!(doc["claudeAiOauth"]["expiresAt"], 99);
698 }
699
700 #[test]
701 fn merge_oauth_handles_empty_and_non_object_input() {
702 let new_oauth = OauthCreds {
703 access_token: "A".into(),
704 refresh_token: "R".into(),
705 expires_at_ms: 0,
706 subscription_type: "pro".into(),
707 rate_limit_tier: "".into(),
708 scopes: None,
709 };
710 let doc = merge_oauth(None, &new_oauth).unwrap();
712 assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
713 let doc = merge_oauth(Some("not json"), &new_oauth).unwrap();
715 assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
716 let doc = merge_oauth(Some("[1,2,3]"), &new_oauth).unwrap();
717 assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
718 }
719
720 #[test]
721 fn write_back_round_trips_and_preserves_unknown_fields() {
722 let (_dir, path) = write_creds_closed(
723 r#"{"claudeAiOauth":{
724 "accessToken":"OLD","refreshToken":"OLD","expiresAt": 0,
725 "subscriptionType":"pro","rateLimitTier":""
726 },"someOtherField":"keep me"}"#,
727 );
728 let creds = read_from(&path).unwrap();
729 let new_oauth = OauthCreds {
730 access_token: "NEW".into(),
731 refresh_token: "NEW_RT".into(),
732 expires_at_ms: 1234,
733 subscription_type: "pro".into(),
734 rate_limit_tier: "".into(),
735 scopes: creds.claude_ai_oauth.scopes.clone(),
736 };
737 write_back(&path, &new_oauth).unwrap();
738 let raw = std::fs::read_to_string(&path).unwrap();
740 let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
741 assert_eq!(v["someOtherField"], "keep me");
742 assert_eq!(v["claudeAiOauth"]["accessToken"], "NEW");
743 assert_eq!(v["claudeAiOauth"]["expiresAt"], 1234);
744 }
745}