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 = crate::format::capitalize(&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
88pub fn default_path() -> Result<PathBuf> {
94 Ok(crate::cache::home_dir()?
95 .join(".claude")
96 .join(".credentials.json"))
97}
98
99pub fn read_from(path: &Path) -> Result<CredentialsFile> {
104 match std::fs::read_to_string(path) {
105 Ok(raw) => parse(&raw, &path.display().to_string()),
106 Err(e) => Err(AppError::io_at(path, e)),
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum CredsTarget {
116 Default(PathBuf),
119 Explicit(PathBuf),
122 Named { path: PathBuf, config_dir: PathBuf },
131 Desktop(super::desktop_creds::DesktopCreds),
135}
136
137impl CredsTarget {
138 pub fn path(&self) -> &Path {
144 match self {
145 CredsTarget::Default(p) | CredsTarget::Explicit(p) => p,
146 CredsTarget::Named { path, .. } => path,
147 CredsTarget::Desktop(d) => d.blob_path(),
148 }
149 }
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum CredsSource {
157 File(PathBuf),
158 Keychain,
161 NamedKeychain(PathBuf),
166 Desktop(super::desktop_creds::Writeback),
170}
171
172pub fn is_unusable(oauth: &OauthCreds) -> bool {
177 oauth.access_token.trim().is_empty()
178 && oauth.refresh_token.trim().is_empty()
179 && oauth.expires_at_ms <= 0
180}
181
182pub fn resolve(target: &CredsTarget) -> Result<(CredentialsFile, CredsSource)> {
186 match target {
187 CredsTarget::Explicit(p) => Ok((read_from(p)?, CredsSource::File(p.clone()))),
188 CredsTarget::Default(p) => {
189 #[cfg(target_os = "macos")]
190 return read_default_with(p, keychain::read_raw);
191 #[cfg(not(target_os = "macos"))]
192 read_default_with(p, || Ok(None))
193 }
194 CredsTarget::Named { path, config_dir } => {
195 #[cfg(target_os = "macos")]
196 return read_named_with(path, config_dir, keychain::read_raw_for);
197 #[cfg(not(target_os = "macos"))]
198 read_named_with(path, config_dir, |_| Ok(None))
199 }
200 CredsTarget::Desktop(desktop) => {
201 let (creds, writeback) = desktop.read()?;
202 Ok((creds, CredsSource::Desktop(writeback)))
203 }
204 }
205}
206
207fn read_default_with(
229 path: &Path,
230 keychain_read: impl Fn() -> Result<Option<String>>,
231) -> Result<(CredentialsFile, CredsSource)> {
232 let file_result = read_from(path);
233 match &file_result {
234 Ok(creds) if !is_unusable(&creds.claude_ai_oauth) => {
235 Ok((file_result?, CredsSource::File(path.to_path_buf())))
236 }
237 _ => match keychain_read()? {
239 Some(raw) => match parse(&raw, "macOS Keychain (Claude Code-credentials)") {
240 Ok(kc) if !is_unusable(&kc.claude_ai_oauth) => Ok((kc, CredsSource::Keychain)),
241 _ => Ok((file_result?, CredsSource::File(path.to_path_buf()))),
243 },
244 None => Ok((file_result?, CredsSource::File(path.to_path_buf()))),
245 },
246 }
247}
248
249fn read_named_with(
270 path: &Path,
271 config_dir: &Path,
272 keychain_read: impl Fn(&Path) -> Result<Option<String>>,
273) -> Result<(CredentialsFile, CredsSource)> {
274 if let Some(raw) = keychain_read(config_dir)?
277 && let Ok(kc) = parse(&raw, "macOS Keychain (named account)")
278 && !is_unusable(&kc.claude_ai_oauth)
279 {
280 return Ok((kc, CredsSource::NamedKeychain(config_dir.to_path_buf())));
281 }
282 Ok((read_from(path)?, CredsSource::File(path.to_path_buf())))
283}
284
285fn parse(raw: &str, source: &str) -> Result<CredentialsFile> {
287 serde_json::from_str(raw).map_err(|e| {
288 AppError::Credentials(format!(
289 "could not parse {source}: {e}. Run `claude` to re-authenticate."
290 ))
291 })
292}
293
294fn merge_oauth(existing: Option<&str>, new_oauth: &OauthCreds) -> Result<serde_json::Value> {
299 let mut doc: serde_json::Value = existing
300 .and_then(|s| serde_json::from_str(s).ok())
301 .unwrap_or_else(|| serde_json::json!({}));
302 if !doc.is_object() {
303 doc = serde_json::json!({});
304 }
305 doc.as_object_mut().expect("just ensured object").insert(
306 "claudeAiOauth".into(),
307 serde_json::to_value(new_oauth).map_err(AppError::Json)?,
308 );
309 Ok(doc)
310}
311
312pub fn write_back_to(source: &CredsSource, new_oauth: &OauthCreds) -> Result<()> {
318 match source {
319 CredsSource::File(path) => write_back(path, new_oauth),
320 #[cfg(target_os = "macos")]
321 CredsSource::Keychain => {
322 let existing = keychain::read_raw()?;
323 let doc = merge_oauth(existing.as_deref(), new_oauth)?;
324 let json = serde_json::to_string(&doc).map_err(AppError::Json)?;
325 keychain::write_raw(&json)
326 }
327 #[cfg(not(target_os = "macos"))]
328 CredsSource::Keychain => Err(AppError::Other(
329 "Keychain credentials source is macOS-only".into(),
330 )),
331 #[cfg(target_os = "macos")]
332 CredsSource::NamedKeychain(config_dir) => {
333 let existing = keychain::read_raw_for(config_dir)?;
334 let doc = merge_oauth(existing.as_deref(), new_oauth)?;
335 let json = serde_json::to_string(&doc).map_err(AppError::Json)?;
336 keychain::write_raw_for(config_dir, &json)
337 }
338 #[cfg(not(target_os = "macos"))]
339 CredsSource::NamedKeychain(_) => Err(AppError::Other(
340 "Keychain credentials source is macOS-only".into(),
341 )),
342 CredsSource::Desktop(writeback) => writeback.write(new_oauth),
343 }
344}
345
346pub fn write_back(path: &Path, new_oauth: &OauthCreds) -> Result<()> {
348 let existing = std::fs::read_to_string(path).ok();
349 let doc = merge_oauth(existing.as_deref(), new_oauth)?;
350 let bytes = serde_json::to_vec_pretty(&doc).map_err(AppError::Json)?;
351 atomic_write(path, &bytes)
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use std::io::Write;
358 use tempfile::{NamedTempFile, TempDir};
359
360 fn write_creds(s: &str) -> NamedTempFile {
361 let mut f = NamedTempFile::new().unwrap();
362 f.write_all(s.as_bytes()).unwrap();
363 f.flush().unwrap();
364 f
365 }
366
367 fn write_creds_closed(s: &str) -> (TempDir, std::path::PathBuf) {
371 crate::cache::closed_temp_file("credentials.json", Some(s))
372 }
373
374 #[test]
375 fn parses_canonical_shape() {
376 let f = write_creds(
377 r#"{"claudeAiOauth":{
378 "accessToken":"AT",
379 "refreshToken":"RT",
380 "expiresAt": 1735000000000,
381 "subscriptionType":"max",
382 "rateLimitTier":"default_claude_max_5x"
383 }}"#,
384 );
385 let creds = read_from(f.path()).unwrap();
386 assert_eq!(creds.claude_ai_oauth.access_token, "AT");
387 assert_eq!(creds.claude_ai_oauth.expires_at_ms, 1735000000000);
388 assert_eq!(creds.claude_ai_oauth.plan_label(), "Max 5x");
389 }
390
391 #[test]
392 fn accepts_float_expires_at() {
393 let f = write_creds(
395 r#"{"claudeAiOauth":{
396 "accessToken":"A","refreshToken":"R",
397 "expiresAt": 5000.0,
398 "subscriptionType":"pro","rateLimitTier":""
399 }}"#,
400 );
401 let creds = read_from(f.path()).unwrap();
402 assert_eq!(creds.claude_ai_oauth.expires_at_ms, 5000);
403 }
404
405 #[test]
406 fn plan_label_pro_no_tier() {
407 let f = write_creds(
408 r#"{"claudeAiOauth":{
409 "accessToken":"A","refreshToken":"R","expiresAt": 0,
410 "subscriptionType":"pro","rateLimitTier":""
411 }}"#,
412 );
413 let creds = read_from(f.path()).unwrap();
414 assert_eq!(creds.claude_ai_oauth.plan_label(), "Pro");
415 }
416
417 #[test]
418 fn plan_label_max_20x() {
419 let f = write_creds(
420 r#"{"claudeAiOauth":{
421 "accessToken":"A","refreshToken":"R","expiresAt": 0,
422 "subscriptionType":"max","rateLimitTier":"default_claude_max_20x"
423 }}"#,
424 );
425 let creds = read_from(f.path()).unwrap();
426 assert_eq!(creds.claude_ai_oauth.plan_label(), "Max 20x");
427 }
428
429 #[test]
430 fn plan_label_empty_subscription_falls_back() {
431 let f = write_creds(
432 r#"{"claudeAiOauth":{
433 "accessToken":"A","refreshToken":"R","expiresAt": 0,
434 "subscriptionType":"","rateLimitTier":""
435 }}"#,
436 );
437 let creds = read_from(f.path()).unwrap();
438 assert_eq!(creds.claude_ai_oauth.plan_label(), "Unknown");
439 }
440
441 #[test]
442 fn malformed_file_returns_credentials_error() {
443 let f = write_creds("not json");
444 let err = read_from(f.path()).unwrap_err();
445 assert!(matches!(err, AppError::Credentials(_)));
446 }
447
448 #[test]
453 fn read_from_missing_file_is_io_error() {
454 let path = std::path::Path::new("/nonexistent/ai-usagebar/.credentials.json");
455 let err = read_from(path).unwrap_err();
456 assert!(matches!(err, AppError::Io { .. }));
457 }
458
459 const USABLE: &str = r#"{"claudeAiOauth":{
464 "accessToken":"live-token","refreshToken":"rt","expiresAt": 9999999999999,
465 "subscriptionType":"max","rateLimitTier":""}}"#;
466 const UNUSABLE: &str = r#"{"claudeAiOauth":{
467 "accessToken":"","refreshToken":"","expiresAt": 0,
468 "subscriptionType":"","rateLimitTier":""}}"#;
469 const KEYCHAIN_USABLE: &str = r#"{"claudeAiOauth":{
470 "accessToken":"kc-token","refreshToken":"kc-rt","expiresAt": 9999999999999,
471 "subscriptionType":"max","rateLimitTier":""}}"#;
472
473 #[test]
474 fn is_unusable_only_when_fully_dead() {
475 let dead: CredentialsFile = serde_json::from_str(UNUSABLE).unwrap();
476 assert!(is_unusable(&dead.claude_ai_oauth));
477 let trusted: CredentialsFile = serde_json::from_str(
480 r#"{"claudeAiOauth":{"accessToken":"live","refreshToken":"",
481 "expiresAt": 9999999999999,"subscriptionType":"max","rateLimitTier":""}}"#,
482 )
483 .unwrap();
484 assert!(!is_unusable(&trusted.claude_ai_oauth));
485 }
486
487 #[test]
488 fn default_read_usable_file_wins_without_consulting_keychain() {
489 let (_dir, path) = write_creds_closed(USABLE);
490 let (creds, source) =
491 read_default_with(&path, || panic!("keychain must not be consulted")).unwrap();
492 assert_eq!(creds.claude_ai_oauth.access_token, "live-token");
493 assert_eq!(source, CredsSource::File(path));
494 }
495
496 #[test]
497 fn default_read_missing_file_falls_back_to_keychain() {
498 let dir = TempDir::new().unwrap();
499 let path = dir.path().join("missing.json");
500 let (creds, source) =
501 read_default_with(&path, || Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
502 assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
503 assert_eq!(source, CredsSource::Keychain);
504 }
505
506 #[test]
507 fn default_read_missing_file_without_keychain_is_io_error() {
508 let dir = TempDir::new().unwrap();
509 let path = dir.path().join("missing.json");
510 let err = read_default_with(&path, || Ok(None)).unwrap_err();
511 assert!(matches!(err, AppError::Io { .. }));
512 }
513
514 #[test]
515 fn a_locked_keychain_wins_over_the_file_missing_error() {
516 let dir = TempDir::new().unwrap();
521 let path = dir.path().join("missing.json");
522 let err = read_default_with(&path, || {
523 Err(AppError::Credentials("the Keychain is locked".into()))
524 })
525 .unwrap_err();
526 assert!(
527 matches!(err, AppError::Credentials(ref m) if m.contains("locked")),
528 "expected the Keychain error to surface, got {err:?}"
529 );
530 }
531
532 #[test]
533 fn default_read_unusable_file_prefers_usable_keychain() {
534 let (_dir, path) = write_creds_closed(UNUSABLE);
536 let (creds, source) =
537 read_default_with(&path, || Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
538 assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
539 assert_eq!(source, CredsSource::Keychain);
540 }
541
542 #[test]
543 fn default_read_unusable_file_kept_when_keychain_absent_or_dead() {
544 let (_dir, path) = write_creds_closed(UNUSABLE);
545 let (creds, source) = read_default_with(&path, || Ok(None)).unwrap();
547 assert!(is_unusable(&creds.claude_ai_oauth));
548 assert_eq!(source, CredsSource::File(path.clone()));
549 let (_, source) = read_default_with(&path, || Ok(Some(UNUSABLE.into()))).unwrap();
551 assert_eq!(source, CredsSource::File(path));
552 }
553
554 #[test]
555 fn default_read_unparsable_file_falls_back_to_keychain_else_errors() {
556 let (_dir, path) = write_creds_closed("not json at all");
557 let (creds, source) =
558 read_default_with(&path, || Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
559 assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
560 assert_eq!(source, CredsSource::Keychain);
561 let err = read_default_with(&path, || Ok(None)).unwrap_err();
563 assert!(matches!(err, AppError::Credentials(_)));
564 }
565
566 #[test]
571 fn named_read_prefers_keychain_over_a_live_looking_file() {
572 let (_dir, path) = write_creds_closed(USABLE);
577 let cfg_dir = path.parent().unwrap().to_path_buf();
578 let (creds, source) =
579 read_named_with(&path, &cfg_dir, |_| Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
580 assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
581 assert_eq!(source, CredsSource::NamedKeychain(cfg_dir));
582 }
583
584 #[test]
585 fn named_read_falls_back_to_file_when_keychain_absent() {
586 let (_dir, path) = write_creds_closed(USABLE);
588 let cfg_dir = path.parent().unwrap().to_path_buf();
589 let (creds, source) = read_named_with(&path, &cfg_dir, |_| Ok(None)).unwrap();
590 assert_eq!(creds.claude_ai_oauth.access_token, "live-token");
591 assert_eq!(source, CredsSource::File(path));
592 }
593
594 #[test]
595 fn named_read_unusable_keychain_falls_back_to_file() {
596 let (_dir, path) = write_creds_closed(USABLE);
597 let cfg_dir = path.parent().unwrap().to_path_buf();
598 let (_, source) = read_named_with(&path, &cfg_dir, |_| Ok(Some(UNUSABLE.into()))).unwrap();
599 assert_eq!(source, CredsSource::File(path.clone()));
600 let (_, source) =
602 read_named_with(&path, &cfg_dir, |_| Ok(Some("not json".into()))).unwrap();
603 assert_eq!(source, CredsSource::File(path));
604 }
605
606 #[test]
607 fn named_read_both_missing_surfaces_the_file_error() {
608 let dir = TempDir::new().unwrap();
609 let path = dir.path().join("missing.json");
610 let err = read_named_with(&path, dir.path(), |_| Ok(None)).unwrap_err();
611 assert!(matches!(err, AppError::Io { .. }));
612 }
613
614 #[test]
615 fn named_read_locked_keychain_error_surfaces_not_the_stale_file() {
616 let (_dir, path) = write_creds_closed(USABLE);
620 let cfg_dir = path.parent().unwrap();
621 let err = read_named_with(&path, cfg_dir, |_| {
622 Err(AppError::Credentials("the Keychain is locked".into()))
623 })
624 .unwrap_err();
625 assert!(matches!(err, AppError::Credentials(ref m) if m.contains("locked")));
626 }
627
628 #[test]
629 fn resolve_explicit_never_falls_back() {
630 let dir = TempDir::new().unwrap();
634 let target = CredsTarget::Explicit(dir.path().join("missing.json"));
635 assert!(matches!(resolve(&target).unwrap_err(), AppError::Io { .. }));
636 }
637
638 #[test]
639 fn default_path_ends_with_claude_credentials() {
640 let p = default_path().unwrap();
641 assert!(p.ends_with(std::path::Path::new(".claude").join(".credentials.json")));
644 }
645
646 #[cfg(windows)]
649 #[test]
650 fn default_path_uses_userprofile_on_windows() {
651 let p = default_path().unwrap();
652 let userprofile = std::env::var("USERPROFILE").expect("USERPROFILE set on Windows");
653 let norm = |s: &str| s.to_lowercase().replace('/', "\\");
658 let p_norm = norm(&p.to_string_lossy());
659 let up_norm = norm(&userprofile);
660 assert!(
661 p_norm.starts_with(up_norm.as_str()),
662 "{} should live under {}",
663 p.display(),
664 userprofile
665 );
666 }
667
668 #[test]
669 fn merge_oauth_preserves_unknown_top_level_fields() {
670 let existing = r#"{"claudeAiOauth":{"accessToken":"OLD"},"mcpOAuth":{"x":1}}"#;
671 let new_oauth = OauthCreds {
672 access_token: "NEW".into(),
673 refresh_token: "RT".into(),
674 expires_at_ms: 99,
675 subscription_type: "max".into(),
676 rate_limit_tier: "".into(),
677 scopes: None,
678 };
679 let doc = merge_oauth(Some(existing), &new_oauth).unwrap();
680 assert_eq!(doc["mcpOAuth"]["x"], 1);
681 assert_eq!(doc["claudeAiOauth"]["accessToken"], "NEW");
682 assert_eq!(doc["claudeAiOauth"]["expiresAt"], 99);
683 }
684
685 #[test]
686 fn merge_oauth_handles_empty_and_non_object_input() {
687 let new_oauth = OauthCreds {
688 access_token: "A".into(),
689 refresh_token: "R".into(),
690 expires_at_ms: 0,
691 subscription_type: "pro".into(),
692 rate_limit_tier: "".into(),
693 scopes: None,
694 };
695 let doc = merge_oauth(None, &new_oauth).unwrap();
697 assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
698 let doc = merge_oauth(Some("not json"), &new_oauth).unwrap();
700 assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
701 let doc = merge_oauth(Some("[1,2,3]"), &new_oauth).unwrap();
702 assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
703 }
704
705 #[test]
706 fn write_back_round_trips_and_preserves_unknown_fields() {
707 let (_dir, path) = write_creds_closed(
708 r#"{"claudeAiOauth":{
709 "accessToken":"OLD","refreshToken":"OLD","expiresAt": 0,
710 "subscriptionType":"pro","rateLimitTier":""
711 },"someOtherField":"keep me"}"#,
712 );
713 let creds = read_from(&path).unwrap();
714 let new_oauth = OauthCreds {
715 access_token: "NEW".into(),
716 refresh_token: "NEW_RT".into(),
717 expires_at_ms: 1234,
718 subscription_type: "pro".into(),
719 rate_limit_tier: "".into(),
720 scopes: creds.claude_ai_oauth.scopes.clone(),
721 };
722 write_back(&path, &new_oauth).unwrap();
723 let raw = std::fs::read_to_string(&path).unwrap();
725 let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
726 assert_eq!(v["someOtherField"], "keep me");
727 assert_eq!(v["claudeAiOauth"]["accessToken"], "NEW");
728 assert_eq!(v["claudeAiOauth"]["expiresAt"], 1234);
729 }
730}