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(
199 path: &Path,
200 keychain_read: impl Fn() -> Result<Option<String>>,
201) -> Result<(CredentialsFile, CredsSource)> {
202 let file_result = read_from(path);
203 match &file_result {
204 Ok(creds) if !is_unusable(&creds.claude_ai_oauth) => {
205 Ok((file_result?, CredsSource::File(path.to_path_buf())))
206 }
207 _ => match keychain_read()? {
209 Some(raw) => match parse(&raw, "macOS Keychain (Claude Code-credentials)") {
210 Ok(kc) if !is_unusable(&kc.claude_ai_oauth) => Ok((kc, CredsSource::Keychain)),
211 _ => Ok((file_result?, CredsSource::File(path.to_path_buf()))),
213 },
214 None => Ok((file_result?, CredsSource::File(path.to_path_buf()))),
215 },
216 }
217}
218
219fn parse(raw: &str, source: &str) -> Result<CredentialsFile> {
221 serde_json::from_str(raw).map_err(|e| {
222 AppError::Credentials(format!(
223 "could not parse {source}: {e}. Run `claude` to re-authenticate."
224 ))
225 })
226}
227
228fn merge_oauth(existing: Option<&str>, new_oauth: &OauthCreds) -> Result<serde_json::Value> {
233 let mut doc: serde_json::Value = existing
234 .and_then(|s| serde_json::from_str(s).ok())
235 .unwrap_or_else(|| serde_json::json!({}));
236 if !doc.is_object() {
237 doc = serde_json::json!({});
238 }
239 doc.as_object_mut().expect("just ensured object").insert(
240 "claudeAiOauth".into(),
241 serde_json::to_value(new_oauth).map_err(AppError::Json)?,
242 );
243 Ok(doc)
244}
245
246pub fn write_back_to(source: &CredsSource, new_oauth: &OauthCreds) -> Result<()> {
252 match source {
253 CredsSource::File(path) => write_back(path, new_oauth),
254 #[cfg(target_os = "macos")]
255 CredsSource::Keychain => {
256 let existing = keychain::read_raw()?;
257 let doc = merge_oauth(existing.as_deref(), new_oauth)?;
258 let json = serde_json::to_string(&doc).map_err(AppError::Json)?;
259 keychain::write_raw(&json)
260 }
261 #[cfg(not(target_os = "macos"))]
262 CredsSource::Keychain => Err(AppError::Other(
263 "Keychain credentials source is macOS-only".into(),
264 )),
265 }
266}
267
268pub fn write_back(path: &Path, new_oauth: &OauthCreds) -> Result<()> {
270 let existing = std::fs::read_to_string(path).ok();
271 let doc = merge_oauth(existing.as_deref(), new_oauth)?;
272 let bytes = serde_json::to_vec_pretty(&doc).map_err(AppError::Json)?;
273 atomic_write(path, &bytes)
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use std::io::Write;
280 use tempfile::{NamedTempFile, TempDir};
281
282 fn write_creds(s: &str) -> NamedTempFile {
283 let mut f = NamedTempFile::new().unwrap();
284 f.write_all(s.as_bytes()).unwrap();
285 f.flush().unwrap();
286 f
287 }
288
289 fn write_creds_closed(s: &str) -> (TempDir, std::path::PathBuf) {
293 crate::cache::closed_temp_file("credentials.json", Some(s))
294 }
295
296 #[test]
297 fn parses_canonical_shape() {
298 let f = write_creds(
299 r#"{"claudeAiOauth":{
300 "accessToken":"AT",
301 "refreshToken":"RT",
302 "expiresAt": 1735000000000,
303 "subscriptionType":"max",
304 "rateLimitTier":"default_claude_max_5x"
305 }}"#,
306 );
307 let creds = read_from(f.path()).unwrap();
308 assert_eq!(creds.claude_ai_oauth.access_token, "AT");
309 assert_eq!(creds.claude_ai_oauth.expires_at_ms, 1735000000000);
310 assert_eq!(creds.claude_ai_oauth.plan_label(), "Max 5x");
311 }
312
313 #[test]
314 fn accepts_float_expires_at() {
315 let f = write_creds(
317 r#"{"claudeAiOauth":{
318 "accessToken":"A","refreshToken":"R",
319 "expiresAt": 5000.0,
320 "subscriptionType":"pro","rateLimitTier":""
321 }}"#,
322 );
323 let creds = read_from(f.path()).unwrap();
324 assert_eq!(creds.claude_ai_oauth.expires_at_ms, 5000);
325 }
326
327 #[test]
328 fn plan_label_pro_no_tier() {
329 let f = write_creds(
330 r#"{"claudeAiOauth":{
331 "accessToken":"A","refreshToken":"R","expiresAt": 0,
332 "subscriptionType":"pro","rateLimitTier":""
333 }}"#,
334 );
335 let creds = read_from(f.path()).unwrap();
336 assert_eq!(creds.claude_ai_oauth.plan_label(), "Pro");
337 }
338
339 #[test]
340 fn plan_label_max_20x() {
341 let f = write_creds(
342 r#"{"claudeAiOauth":{
343 "accessToken":"A","refreshToken":"R","expiresAt": 0,
344 "subscriptionType":"max","rateLimitTier":"default_claude_max_20x"
345 }}"#,
346 );
347 let creds = read_from(f.path()).unwrap();
348 assert_eq!(creds.claude_ai_oauth.plan_label(), "Max 20x");
349 }
350
351 #[test]
352 fn plan_label_empty_subscription_falls_back() {
353 let f = write_creds(
354 r#"{"claudeAiOauth":{
355 "accessToken":"A","refreshToken":"R","expiresAt": 0,
356 "subscriptionType":"","rateLimitTier":""
357 }}"#,
358 );
359 let creds = read_from(f.path()).unwrap();
360 assert_eq!(creds.claude_ai_oauth.plan_label(), "Unknown");
361 }
362
363 #[test]
364 fn malformed_file_returns_credentials_error() {
365 let f = write_creds("not json");
366 let err = read_from(f.path()).unwrap_err();
367 assert!(matches!(err, AppError::Credentials(_)));
368 }
369
370 #[test]
375 fn read_from_missing_file_is_io_error() {
376 let path = std::path::Path::new("/nonexistent/ai-usagebar/.credentials.json");
377 let err = read_from(path).unwrap_err();
378 assert!(matches!(err, AppError::Io { .. }));
379 }
380
381 const USABLE: &str = r#"{"claudeAiOauth":{
386 "accessToken":"live-token","refreshToken":"rt","expiresAt": 9999999999999,
387 "subscriptionType":"max","rateLimitTier":""}}"#;
388 const UNUSABLE: &str = r#"{"claudeAiOauth":{
389 "accessToken":"","refreshToken":"","expiresAt": 0,
390 "subscriptionType":"","rateLimitTier":""}}"#;
391 const KEYCHAIN_USABLE: &str = r#"{"claudeAiOauth":{
392 "accessToken":"kc-token","refreshToken":"kc-rt","expiresAt": 9999999999999,
393 "subscriptionType":"max","rateLimitTier":""}}"#;
394
395 #[test]
396 fn is_unusable_only_when_fully_dead() {
397 let dead: CredentialsFile = serde_json::from_str(UNUSABLE).unwrap();
398 assert!(is_unusable(&dead.claude_ai_oauth));
399 let trusted: CredentialsFile = serde_json::from_str(
402 r#"{"claudeAiOauth":{"accessToken":"live","refreshToken":"",
403 "expiresAt": 9999999999999,"subscriptionType":"max","rateLimitTier":""}}"#,
404 )
405 .unwrap();
406 assert!(!is_unusable(&trusted.claude_ai_oauth));
407 }
408
409 #[test]
410 fn default_read_usable_file_wins_without_consulting_keychain() {
411 let (_dir, path) = write_creds_closed(USABLE);
412 let (creds, source) =
413 read_default_with(&path, || panic!("keychain must not be consulted")).unwrap();
414 assert_eq!(creds.claude_ai_oauth.access_token, "live-token");
415 assert_eq!(source, CredsSource::File(path));
416 }
417
418 #[test]
419 fn default_read_missing_file_falls_back_to_keychain() {
420 let dir = TempDir::new().unwrap();
421 let path = dir.path().join("missing.json");
422 let (creds, source) =
423 read_default_with(&path, || Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
424 assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
425 assert_eq!(source, CredsSource::Keychain);
426 }
427
428 #[test]
429 fn default_read_missing_file_without_keychain_is_io_error() {
430 let dir = TempDir::new().unwrap();
431 let path = dir.path().join("missing.json");
432 let err = read_default_with(&path, || Ok(None)).unwrap_err();
433 assert!(matches!(err, AppError::Io { .. }));
434 }
435
436 #[test]
437 fn default_read_unusable_file_prefers_usable_keychain() {
438 let (_dir, path) = write_creds_closed(UNUSABLE);
440 let (creds, source) =
441 read_default_with(&path, || Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
442 assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
443 assert_eq!(source, CredsSource::Keychain);
444 }
445
446 #[test]
447 fn default_read_unusable_file_kept_when_keychain_absent_or_dead() {
448 let (_dir, path) = write_creds_closed(UNUSABLE);
449 let (creds, source) = read_default_with(&path, || Ok(None)).unwrap();
451 assert!(is_unusable(&creds.claude_ai_oauth));
452 assert_eq!(source, CredsSource::File(path.clone()));
453 let (_, source) = read_default_with(&path, || Ok(Some(UNUSABLE.into()))).unwrap();
455 assert_eq!(source, CredsSource::File(path));
456 }
457
458 #[test]
459 fn default_read_unparsable_file_falls_back_to_keychain_else_errors() {
460 let (_dir, path) = write_creds_closed("not json at all");
461 let (creds, source) =
462 read_default_with(&path, || Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
463 assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
464 assert_eq!(source, CredsSource::Keychain);
465 let err = read_default_with(&path, || Ok(None)).unwrap_err();
467 assert!(matches!(err, AppError::Credentials(_)));
468 }
469
470 #[test]
471 fn resolve_explicit_never_falls_back() {
472 let dir = TempDir::new().unwrap();
476 let target = CredsTarget::Explicit(dir.path().join("missing.json"));
477 assert!(matches!(resolve(&target).unwrap_err(), AppError::Io { .. }));
478 }
479
480 #[test]
481 fn default_path_ends_with_claude_credentials() {
482 let p = default_path().unwrap();
483 assert!(p.ends_with(std::path::Path::new(".claude").join(".credentials.json")));
486 }
487
488 #[cfg(windows)]
491 #[test]
492 fn default_path_uses_userprofile_on_windows() {
493 let p = default_path().unwrap();
494 let userprofile = std::env::var("USERPROFILE").expect("USERPROFILE set on Windows");
495 let norm = |s: &str| s.to_lowercase().replace('/', "\\");
500 let p_norm = norm(&p.to_string_lossy());
501 let up_norm = norm(&userprofile);
502 assert!(
503 p_norm.starts_with(up_norm.as_str()),
504 "{} should live under {}",
505 p.display(),
506 userprofile
507 );
508 }
509
510 #[test]
511 fn merge_oauth_preserves_unknown_top_level_fields() {
512 let existing = r#"{"claudeAiOauth":{"accessToken":"OLD"},"mcpOAuth":{"x":1}}"#;
513 let new_oauth = OauthCreds {
514 access_token: "NEW".into(),
515 refresh_token: "RT".into(),
516 expires_at_ms: 99,
517 subscription_type: "max".into(),
518 rate_limit_tier: "".into(),
519 scopes: None,
520 };
521 let doc = merge_oauth(Some(existing), &new_oauth).unwrap();
522 assert_eq!(doc["mcpOAuth"]["x"], 1);
523 assert_eq!(doc["claudeAiOauth"]["accessToken"], "NEW");
524 assert_eq!(doc["claudeAiOauth"]["expiresAt"], 99);
525 }
526
527 #[test]
528 fn merge_oauth_handles_empty_and_non_object_input() {
529 let new_oauth = OauthCreds {
530 access_token: "A".into(),
531 refresh_token: "R".into(),
532 expires_at_ms: 0,
533 subscription_type: "pro".into(),
534 rate_limit_tier: "".into(),
535 scopes: None,
536 };
537 let doc = merge_oauth(None, &new_oauth).unwrap();
539 assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
540 let doc = merge_oauth(Some("not json"), &new_oauth).unwrap();
542 assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
543 let doc = merge_oauth(Some("[1,2,3]"), &new_oauth).unwrap();
544 assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
545 }
546
547 #[test]
548 fn write_back_round_trips_and_preserves_unknown_fields() {
549 let (_dir, path) = write_creds_closed(
550 r#"{"claudeAiOauth":{
551 "accessToken":"OLD","refreshToken":"OLD","expiresAt": 0,
552 "subscriptionType":"pro","rateLimitTier":""
553 },"someOtherField":"keep me"}"#,
554 );
555 let creds = read_from(&path).unwrap();
556 let new_oauth = OauthCreds {
557 access_token: "NEW".into(),
558 refresh_token: "NEW_RT".into(),
559 expires_at_ms: 1234,
560 subscription_type: "pro".into(),
561 rate_limit_tier: "".into(),
562 scopes: creds.claude_ai_oauth.scopes.clone(),
563 };
564 write_back(&path, &new_oauth).unwrap();
565 let raw = std::fs::read_to_string(&path).unwrap();
567 let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
568 assert_eq!(v["someOtherField"], "keep me");
569 assert_eq!(v["claudeAiOauth"]["accessToken"], "NEW");
570 assert_eq!(v["claudeAiOauth"]["expiresAt"], 1234);
571 }
572}