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}
147
148impl CredsTarget {
149 pub fn path(&self) -> &Path {
150 match self {
151 CredsTarget::Default(p) | CredsTarget::Explicit(p) => p,
152 CredsTarget::Named { path, .. } => path,
153 }
154 }
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum CredsSource {
162 File(PathBuf),
163 Keychain,
166 NamedKeychain(PathBuf),
171}
172
173pub fn is_unusable(oauth: &OauthCreds) -> bool {
178 oauth.access_token.trim().is_empty()
179 && oauth.refresh_token.trim().is_empty()
180 && oauth.expires_at_ms <= 0
181}
182
183pub fn resolve(target: &CredsTarget) -> Result<(CredentialsFile, CredsSource)> {
187 match target {
188 CredsTarget::Explicit(p) => Ok((read_from(p)?, CredsSource::File(p.clone()))),
189 CredsTarget::Default(p) => {
190 #[cfg(target_os = "macos")]
191 return read_default_with(p, keychain::read_raw);
192 #[cfg(not(target_os = "macos"))]
193 read_default_with(p, || Ok(None))
194 }
195 CredsTarget::Named { path, config_dir } => {
196 #[cfg(target_os = "macos")]
197 return read_named_with(path, config_dir, keychain::read_raw_for);
198 #[cfg(not(target_os = "macos"))]
199 read_named_with(path, config_dir, |_| Ok(None))
200 }
201 }
202}
203
204fn read_default_with(
226 path: &Path,
227 keychain_read: impl Fn() -> Result<Option<String>>,
228) -> Result<(CredentialsFile, CredsSource)> {
229 let file_result = read_from(path);
230 match &file_result {
231 Ok(creds) if !is_unusable(&creds.claude_ai_oauth) => {
232 Ok((file_result?, CredsSource::File(path.to_path_buf())))
233 }
234 _ => match keychain_read()? {
236 Some(raw) => match parse(&raw, "macOS Keychain (Claude Code-credentials)") {
237 Ok(kc) if !is_unusable(&kc.claude_ai_oauth) => Ok((kc, CredsSource::Keychain)),
238 _ => Ok((file_result?, CredsSource::File(path.to_path_buf()))),
240 },
241 None => Ok((file_result?, CredsSource::File(path.to_path_buf()))),
242 },
243 }
244}
245
246fn read_named_with(
267 path: &Path,
268 config_dir: &Path,
269 keychain_read: impl Fn(&Path) -> Result<Option<String>>,
270) -> Result<(CredentialsFile, CredsSource)> {
271 if let Some(raw) = keychain_read(config_dir)?
274 && let Ok(kc) = parse(&raw, "macOS Keychain (named account)")
275 && !is_unusable(&kc.claude_ai_oauth)
276 {
277 return Ok((kc, CredsSource::NamedKeychain(config_dir.to_path_buf())));
278 }
279 Ok((read_from(path)?, CredsSource::File(path.to_path_buf())))
280}
281
282fn parse(raw: &str, source: &str) -> Result<CredentialsFile> {
284 serde_json::from_str(raw).map_err(|e| {
285 AppError::Credentials(format!(
286 "could not parse {source}: {e}. Run `claude` to re-authenticate."
287 ))
288 })
289}
290
291fn merge_oauth(existing: Option<&str>, new_oauth: &OauthCreds) -> Result<serde_json::Value> {
296 let mut doc: serde_json::Value = existing
297 .and_then(|s| serde_json::from_str(s).ok())
298 .unwrap_or_else(|| serde_json::json!({}));
299 if !doc.is_object() {
300 doc = serde_json::json!({});
301 }
302 doc.as_object_mut().expect("just ensured object").insert(
303 "claudeAiOauth".into(),
304 serde_json::to_value(new_oauth).map_err(AppError::Json)?,
305 );
306 Ok(doc)
307}
308
309pub fn write_back_to(source: &CredsSource, new_oauth: &OauthCreds) -> Result<()> {
315 match source {
316 CredsSource::File(path) => write_back(path, new_oauth),
317 #[cfg(target_os = "macos")]
318 CredsSource::Keychain => {
319 let existing = keychain::read_raw()?;
320 let doc = merge_oauth(existing.as_deref(), new_oauth)?;
321 let json = serde_json::to_string(&doc).map_err(AppError::Json)?;
322 keychain::write_raw(&json)
323 }
324 #[cfg(not(target_os = "macos"))]
325 CredsSource::Keychain => Err(AppError::Other(
326 "Keychain credentials source is macOS-only".into(),
327 )),
328 #[cfg(target_os = "macos")]
329 CredsSource::NamedKeychain(config_dir) => {
330 let existing = keychain::read_raw_for(config_dir)?;
331 let doc = merge_oauth(existing.as_deref(), new_oauth)?;
332 let json = serde_json::to_string(&doc).map_err(AppError::Json)?;
333 keychain::write_raw_for(config_dir, &json)
334 }
335 #[cfg(not(target_os = "macos"))]
336 CredsSource::NamedKeychain(_) => Err(AppError::Other(
337 "Keychain credentials source is macOS-only".into(),
338 )),
339 }
340}
341
342pub fn write_back(path: &Path, new_oauth: &OauthCreds) -> Result<()> {
344 let existing = std::fs::read_to_string(path).ok();
345 let doc = merge_oauth(existing.as_deref(), new_oauth)?;
346 let bytes = serde_json::to_vec_pretty(&doc).map_err(AppError::Json)?;
347 atomic_write(path, &bytes)
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use std::io::Write;
354 use tempfile::{NamedTempFile, TempDir};
355
356 fn write_creds(s: &str) -> NamedTempFile {
357 let mut f = NamedTempFile::new().unwrap();
358 f.write_all(s.as_bytes()).unwrap();
359 f.flush().unwrap();
360 f
361 }
362
363 fn write_creds_closed(s: &str) -> (TempDir, std::path::PathBuf) {
367 crate::cache::closed_temp_file("credentials.json", Some(s))
368 }
369
370 #[test]
371 fn parses_canonical_shape() {
372 let f = write_creds(
373 r#"{"claudeAiOauth":{
374 "accessToken":"AT",
375 "refreshToken":"RT",
376 "expiresAt": 1735000000000,
377 "subscriptionType":"max",
378 "rateLimitTier":"default_claude_max_5x"
379 }}"#,
380 );
381 let creds = read_from(f.path()).unwrap();
382 assert_eq!(creds.claude_ai_oauth.access_token, "AT");
383 assert_eq!(creds.claude_ai_oauth.expires_at_ms, 1735000000000);
384 assert_eq!(creds.claude_ai_oauth.plan_label(), "Max 5x");
385 }
386
387 #[test]
388 fn accepts_float_expires_at() {
389 let f = write_creds(
391 r#"{"claudeAiOauth":{
392 "accessToken":"A","refreshToken":"R",
393 "expiresAt": 5000.0,
394 "subscriptionType":"pro","rateLimitTier":""
395 }}"#,
396 );
397 let creds = read_from(f.path()).unwrap();
398 assert_eq!(creds.claude_ai_oauth.expires_at_ms, 5000);
399 }
400
401 #[test]
402 fn plan_label_pro_no_tier() {
403 let f = write_creds(
404 r#"{"claudeAiOauth":{
405 "accessToken":"A","refreshToken":"R","expiresAt": 0,
406 "subscriptionType":"pro","rateLimitTier":""
407 }}"#,
408 );
409 let creds = read_from(f.path()).unwrap();
410 assert_eq!(creds.claude_ai_oauth.plan_label(), "Pro");
411 }
412
413 #[test]
414 fn plan_label_max_20x() {
415 let f = write_creds(
416 r#"{"claudeAiOauth":{
417 "accessToken":"A","refreshToken":"R","expiresAt": 0,
418 "subscriptionType":"max","rateLimitTier":"default_claude_max_20x"
419 }}"#,
420 );
421 let creds = read_from(f.path()).unwrap();
422 assert_eq!(creds.claude_ai_oauth.plan_label(), "Max 20x");
423 }
424
425 #[test]
426 fn plan_label_empty_subscription_falls_back() {
427 let f = write_creds(
428 r#"{"claudeAiOauth":{
429 "accessToken":"A","refreshToken":"R","expiresAt": 0,
430 "subscriptionType":"","rateLimitTier":""
431 }}"#,
432 );
433 let creds = read_from(f.path()).unwrap();
434 assert_eq!(creds.claude_ai_oauth.plan_label(), "Unknown");
435 }
436
437 #[test]
438 fn malformed_file_returns_credentials_error() {
439 let f = write_creds("not json");
440 let err = read_from(f.path()).unwrap_err();
441 assert!(matches!(err, AppError::Credentials(_)));
442 }
443
444 #[test]
449 fn read_from_missing_file_is_io_error() {
450 let path = std::path::Path::new("/nonexistent/ai-usagebar/.credentials.json");
451 let err = read_from(path).unwrap_err();
452 assert!(matches!(err, AppError::Io { .. }));
453 }
454
455 const USABLE: &str = r#"{"claudeAiOauth":{
460 "accessToken":"live-token","refreshToken":"rt","expiresAt": 9999999999999,
461 "subscriptionType":"max","rateLimitTier":""}}"#;
462 const UNUSABLE: &str = r#"{"claudeAiOauth":{
463 "accessToken":"","refreshToken":"","expiresAt": 0,
464 "subscriptionType":"","rateLimitTier":""}}"#;
465 const KEYCHAIN_USABLE: &str = r#"{"claudeAiOauth":{
466 "accessToken":"kc-token","refreshToken":"kc-rt","expiresAt": 9999999999999,
467 "subscriptionType":"max","rateLimitTier":""}}"#;
468
469 #[test]
470 fn is_unusable_only_when_fully_dead() {
471 let dead: CredentialsFile = serde_json::from_str(UNUSABLE).unwrap();
472 assert!(is_unusable(&dead.claude_ai_oauth));
473 let trusted: CredentialsFile = serde_json::from_str(
476 r#"{"claudeAiOauth":{"accessToken":"live","refreshToken":"",
477 "expiresAt": 9999999999999,"subscriptionType":"max","rateLimitTier":""}}"#,
478 )
479 .unwrap();
480 assert!(!is_unusable(&trusted.claude_ai_oauth));
481 }
482
483 #[test]
484 fn default_read_usable_file_wins_without_consulting_keychain() {
485 let (_dir, path) = write_creds_closed(USABLE);
486 let (creds, source) =
487 read_default_with(&path, || panic!("keychain must not be consulted")).unwrap();
488 assert_eq!(creds.claude_ai_oauth.access_token, "live-token");
489 assert_eq!(source, CredsSource::File(path));
490 }
491
492 #[test]
493 fn default_read_missing_file_falls_back_to_keychain() {
494 let dir = TempDir::new().unwrap();
495 let path = dir.path().join("missing.json");
496 let (creds, source) =
497 read_default_with(&path, || Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
498 assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
499 assert_eq!(source, CredsSource::Keychain);
500 }
501
502 #[test]
503 fn default_read_missing_file_without_keychain_is_io_error() {
504 let dir = TempDir::new().unwrap();
505 let path = dir.path().join("missing.json");
506 let err = read_default_with(&path, || Ok(None)).unwrap_err();
507 assert!(matches!(err, AppError::Io { .. }));
508 }
509
510 #[test]
511 fn a_locked_keychain_wins_over_the_file_missing_error() {
512 let dir = TempDir::new().unwrap();
517 let path = dir.path().join("missing.json");
518 let err = read_default_with(&path, || {
519 Err(AppError::Credentials("the Keychain is locked".into()))
520 })
521 .unwrap_err();
522 assert!(
523 matches!(err, AppError::Credentials(ref m) if m.contains("locked")),
524 "expected the Keychain error to surface, got {err:?}"
525 );
526 }
527
528 #[test]
529 fn default_read_unusable_file_prefers_usable_keychain() {
530 let (_dir, path) = write_creds_closed(UNUSABLE);
532 let (creds, source) =
533 read_default_with(&path, || Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
534 assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
535 assert_eq!(source, CredsSource::Keychain);
536 }
537
538 #[test]
539 fn default_read_unusable_file_kept_when_keychain_absent_or_dead() {
540 let (_dir, path) = write_creds_closed(UNUSABLE);
541 let (creds, source) = read_default_with(&path, || Ok(None)).unwrap();
543 assert!(is_unusable(&creds.claude_ai_oauth));
544 assert_eq!(source, CredsSource::File(path.clone()));
545 let (_, source) = read_default_with(&path, || Ok(Some(UNUSABLE.into()))).unwrap();
547 assert_eq!(source, CredsSource::File(path));
548 }
549
550 #[test]
551 fn default_read_unparsable_file_falls_back_to_keychain_else_errors() {
552 let (_dir, path) = write_creds_closed("not json at all");
553 let (creds, source) =
554 read_default_with(&path, || Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
555 assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
556 assert_eq!(source, CredsSource::Keychain);
557 let err = read_default_with(&path, || Ok(None)).unwrap_err();
559 assert!(matches!(err, AppError::Credentials(_)));
560 }
561
562 #[test]
567 fn named_read_prefers_keychain_over_a_live_looking_file() {
568 let (_dir, path) = write_creds_closed(USABLE);
573 let cfg_dir = path.parent().unwrap().to_path_buf();
574 let (creds, source) =
575 read_named_with(&path, &cfg_dir, |_| Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
576 assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
577 assert_eq!(source, CredsSource::NamedKeychain(cfg_dir));
578 }
579
580 #[test]
581 fn named_read_falls_back_to_file_when_keychain_absent() {
582 let (_dir, path) = write_creds_closed(USABLE);
584 let cfg_dir = path.parent().unwrap().to_path_buf();
585 let (creds, source) = read_named_with(&path, &cfg_dir, |_| Ok(None)).unwrap();
586 assert_eq!(creds.claude_ai_oauth.access_token, "live-token");
587 assert_eq!(source, CredsSource::File(path));
588 }
589
590 #[test]
591 fn named_read_unusable_keychain_falls_back_to_file() {
592 let (_dir, path) = write_creds_closed(USABLE);
593 let cfg_dir = path.parent().unwrap().to_path_buf();
594 let (_, source) = read_named_with(&path, &cfg_dir, |_| Ok(Some(UNUSABLE.into()))).unwrap();
595 assert_eq!(source, CredsSource::File(path.clone()));
596 let (_, source) =
598 read_named_with(&path, &cfg_dir, |_| Ok(Some("not json".into()))).unwrap();
599 assert_eq!(source, CredsSource::File(path));
600 }
601
602 #[test]
603 fn named_read_both_missing_surfaces_the_file_error() {
604 let dir = TempDir::new().unwrap();
605 let path = dir.path().join("missing.json");
606 let err = read_named_with(&path, dir.path(), |_| Ok(None)).unwrap_err();
607 assert!(matches!(err, AppError::Io { .. }));
608 }
609
610 #[test]
611 fn named_read_locked_keychain_error_surfaces_not_the_stale_file() {
612 let (_dir, path) = write_creds_closed(USABLE);
616 let cfg_dir = path.parent().unwrap();
617 let err = read_named_with(&path, cfg_dir, |_| {
618 Err(AppError::Credentials("the Keychain is locked".into()))
619 })
620 .unwrap_err();
621 assert!(matches!(err, AppError::Credentials(ref m) if m.contains("locked")));
622 }
623
624 #[test]
625 fn resolve_explicit_never_falls_back() {
626 let dir = TempDir::new().unwrap();
630 let target = CredsTarget::Explicit(dir.path().join("missing.json"));
631 assert!(matches!(resolve(&target).unwrap_err(), AppError::Io { .. }));
632 }
633
634 #[test]
635 fn default_path_ends_with_claude_credentials() {
636 let p = default_path().unwrap();
637 assert!(p.ends_with(std::path::Path::new(".claude").join(".credentials.json")));
640 }
641
642 #[cfg(windows)]
645 #[test]
646 fn default_path_uses_userprofile_on_windows() {
647 let p = default_path().unwrap();
648 let userprofile = std::env::var("USERPROFILE").expect("USERPROFILE set on Windows");
649 let norm = |s: &str| s.to_lowercase().replace('/', "\\");
654 let p_norm = norm(&p.to_string_lossy());
655 let up_norm = norm(&userprofile);
656 assert!(
657 p_norm.starts_with(up_norm.as_str()),
658 "{} should live under {}",
659 p.display(),
660 userprofile
661 );
662 }
663
664 #[test]
665 fn merge_oauth_preserves_unknown_top_level_fields() {
666 let existing = r#"{"claudeAiOauth":{"accessToken":"OLD"},"mcpOAuth":{"x":1}}"#;
667 let new_oauth = OauthCreds {
668 access_token: "NEW".into(),
669 refresh_token: "RT".into(),
670 expires_at_ms: 99,
671 subscription_type: "max".into(),
672 rate_limit_tier: "".into(),
673 scopes: None,
674 };
675 let doc = merge_oauth(Some(existing), &new_oauth).unwrap();
676 assert_eq!(doc["mcpOAuth"]["x"], 1);
677 assert_eq!(doc["claudeAiOauth"]["accessToken"], "NEW");
678 assert_eq!(doc["claudeAiOauth"]["expiresAt"], 99);
679 }
680
681 #[test]
682 fn merge_oauth_handles_empty_and_non_object_input() {
683 let new_oauth = OauthCreds {
684 access_token: "A".into(),
685 refresh_token: "R".into(),
686 expires_at_ms: 0,
687 subscription_type: "pro".into(),
688 rate_limit_tier: "".into(),
689 scopes: None,
690 };
691 let doc = merge_oauth(None, &new_oauth).unwrap();
693 assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
694 let doc = merge_oauth(Some("not json"), &new_oauth).unwrap();
696 assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
697 let doc = merge_oauth(Some("[1,2,3]"), &new_oauth).unwrap();
698 assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
699 }
700
701 #[test]
702 fn write_back_round_trips_and_preserves_unknown_fields() {
703 let (_dir, path) = write_creds_closed(
704 r#"{"claudeAiOauth":{
705 "accessToken":"OLD","refreshToken":"OLD","expiresAt": 0,
706 "subscriptionType":"pro","rateLimitTier":""
707 },"someOtherField":"keep me"}"#,
708 );
709 let creds = read_from(&path).unwrap();
710 let new_oauth = OauthCreds {
711 access_token: "NEW".into(),
712 refresh_token: "NEW_RT".into(),
713 expires_at_ms: 1234,
714 subscription_type: "pro".into(),
715 rate_limit_tier: "".into(),
716 scopes: creds.claude_ai_oauth.scopes.clone(),
717 };
718 write_back(&path, &new_oauth).unwrap();
719 let raw = std::fs::read_to_string(&path).unwrap();
721 let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
722 assert_eq!(v["someOtherField"], "keep me");
723 assert_eq!(v["claudeAiOauth"]["accessToken"], "NEW");
724 assert_eq!(v["claudeAiOauth"]["expiresAt"], 1234);
725 }
726}