Skip to main content

ai_usagebar/anthropic/
creds.rs

1//! Read and write `~/.claude/.credentials.json` — the OAuth state the Claude
2//! CLI maintains. Mirrors claudebar:330-333 (read) and claudebar:447-452 (write).
3//!
4//! On macOS the file often doesn't exist: recent Claude Code builds keep the
5//! same JSON in the login Keychain instead. For the *default* location we fall
6//! back to [`keychain`] when the file is missing or clearly unusable (#15);
7//! explicit paths (`--creds-path`, config, named accounts) are read strictly.
8
9use 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/// Disk shape (matches claudebar's jq paths).
20#[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    /// Unix epoch in **milliseconds** (claudebar:445 multiplies seconds × 1000).
33    /// May arrive as a float in the wild — claudebar truncates with `%%.*`,
34    /// so we accept both.
35    #[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    /// Optional `scopes` array — preserved through round-trips so we don't
42    /// drop information when we write back after a refresh.
43    #[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    // Accept int or float — float values like 5000.0 are truncated.
52    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    /// Plan label rendered the way claudebar does (claudebar:547-550):
69    ///   "${sub_type^} [5x|20x]" (first letter capitalized, optional tier suffix).
70    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
103/// Default location: `~/.claude/.credentials.json` (Unix/macOS) or
104/// `%USERPROFILE%\.claude\.credentials.json` (Windows).
105///
106/// Home is resolved through [`crate::cache::home_dir`] so every platform's
107/// convention is honored in one place.
108pub fn default_path() -> Result<PathBuf> {
109    Ok(crate::cache::home_dir()?
110        .join(".claude")
111        .join(".credentials.json"))
112}
113
114/// Strict file read: no Keychain fallback, ever. Explicit paths (`--creds-path`,
115/// config `credentials_path`, named accounts) go through here so a missing or
116/// broken file fails loudly instead of silently reading a *different* account's
117/// credentials from the Keychain (issues #14/#15).
118pub 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/// Which credentials location a fetch should use. Only the platform-default
126/// location is eligible for the macOS Keychain fallback — Claude Code owns
127/// that location, so "look where Claude Code lives" includes its Keychain
128/// item. An explicit path is a user decision and is honored strictly.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum CredsTarget {
131    /// `~/.claude/.credentials.json` (or the Windows equivalent) — falls back
132    /// to the macOS Keychain when the file is missing *or unusable* (#15).
133    Default(PathBuf),
134    /// `--creds-path` or config `credentials_path` with no `CLAUDE_CONFIG_DIR`
135    /// of its own — never consults the Keychain.
136    Explicit(PathBuf),
137    /// A named account (`[[anthropic.accounts]]` or `accounts_dir`): prefers
138    /// the macOS Keychain item scoped to `config_dir` (`path`'s parent, the
139    /// account's own `CLAUDE_CONFIG_DIR`) because that is where
140    /// `CLAUDE_CONFIG_DIR=<dir> claude` actually writes on macOS; `path` is
141    /// the fallback for the Linux layout and hand-managed files (see
142    /// [`read_named_with`] for the full decision table). Safe unlike a shared
143    /// fallback would be: the Keychain item is hashed from `config_dir`, so
144    /// it can never resolve to a *different* account's credentials.
145    Named { path: PathBuf, config_dir: PathBuf },
146    /// The Claude **Desktop app's** own encrypted token store — no `claude` CLI
147    /// login required. Read/decrypt/write-back all live in
148    /// [`super::desktop_creds`]; this variant just carries the resolved source.
149    Desktop(super::desktop_creds::DesktopCreds),
150}
151
152impl CredsTarget {
153    /// Filesystem path backing this target, for diagnostics. A [`Desktop`]
154    /// source has no single credential file (it decrypts a blob), so its
155    /// blob path stands in.
156    ///
157    /// [`Desktop`]: CredsTarget::Desktop
158    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/// Where credentials were actually read from. Write-backs must follow this —
168/// refreshing tokens read from the Keychain into a stale file (or vice versa)
169/// would fork the credential state Claude Code depends on.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub enum CredsSource {
172    File(PathBuf),
173    /// Only produced on macOS in production (the login Keychain item
174    /// `Claude Code-credentials`).
175    Keychain,
176    /// Only produced on macOS in production: a named account's
177    /// `CLAUDE_CONFIG_DIR`-scoped Keychain item (`Claude
178    /// Code-credentials-<hash>`), keyed by the account's config dir so
179    /// write-backs land in the same per-account item they were read from.
180    NamedKeychain(PathBuf),
181    /// The Claude Desktop token store; the handle re-encrypts a refreshed token
182    /// back into the same blob it was read from (a no-op for a read-only
183    /// active account).
184    Desktop(super::desktop_creds::Writeback),
185}
186
187/// Credentials that can't possibly authenticate anything: no access token, no
188/// refresh token, no future expiry. This is the #15 predicate — deliberately
189/// narrow so the v0.7.2 trusted-device shape (empty `refreshToken` but a live
190/// `accessToken`) keeps its file-first behavior.
191pub 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
197/// Resolve a [`CredsTarget`] to actual credentials + the source they came
198/// from. `Explicit` is a strict [`read_from`]; `Default` adds the macOS
199/// Keychain fallback.
200pub 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
222/// Default-location read with an injectable Keychain reader, so the fallback
223/// selection is unit-testable on any platform (the hermeticity invariant —
224/// tests must never touch a real Keychain). Decision table:
225///
226/// | file state          | keychain        | outcome                    |
227/// |---------------------|-----------------|----------------------------|
228/// | usable              | (not consulted*)| file                       |
229/// | missing             | usable          | keychain                   |
230/// | missing             | absent          | original I/O error         |
231/// | unusable (#15)      | usable          | keychain                   |
232/// | unusable (#15)      | absent          | file result, unchanged     |
233/// | unparsable JSON     | usable          | keychain                   |
234/// | unparsable JSON     | absent          | original parse error       |
235/// | any                 | **unreadable**  | the Keychain error         |
236///
237/// The last row matters: a *locked* login Keychain or a denied ACL is not the
238/// same as "no credentials". Reporting it as absent surfaced a "run `claude`"
239/// message and sent users to re-authenticate while their credentials were
240/// sitting there intact, so that failure now wins over the file's own error.
241///
242/// *usable file short-circuits — no `security(1)` subprocess on the happy path.
243fn 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        // Missing, unusable, or unparsable — see if the Keychain has better.
253        _ => 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                // Keychain no better than the file — surface the file outcome.
257                _ => 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
264/// Named-account read: the account's `CLAUDE_CONFIG_DIR`-scoped Keychain item
265/// wins over the file. On macOS the `claude` CLI *always* writes logins to the
266/// Keychain — `CLAUDE_CONFIG_DIR=<dir> claude` lands in the dir-scoped item,
267/// never in `<dir>/.credentials.json` — so a file there is at best a hand-made
268/// snapshot. Snapshots die: their refresh-token lineage rotates away the next
269/// time the *real* holder refreshes, and the copy 401s within hours (observed
270/// in production; that failure is what motivated this order). Decision table:
271///
272/// | keychain item              | file             | outcome            |
273/// |----------------------------|------------------|--------------------|
274/// | usable                     | (not consulted)  | keychain           |
275/// | absent/unusable/unparsable | readable         | file               |
276/// | absent/unusable/unparsable | missing/broken   | the file's error   |
277/// | **unreadable** (locked/ACL)| any              | the Keychain error |
278///
279/// The unreadable row follows [`read_default_with`]'s reasoning: a locked
280/// Keychain is not "no credentials", and silently dropping to a stale file
281/// snapshot would produce exactly the confusing half-dead state this order
282/// exists to prevent. On non-macOS the injected reader returns `None`, so the
283/// file is simply read strictly — the Linux layout is file-only.
284fn read_named_with(
285    path: &Path,
286    config_dir: &Path,
287    keychain_read: impl Fn(&Path) -> Result<Option<String>>,
288) -> Result<(CredentialsFile, CredsSource)> {
289    // A present-but-unparsable/unusable item is no better than the file, so
290    // the whole chain failing falls through to the strict file read.
291    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
300/// Parse a credentials JSON blob from any source (`source` only labels errors).
301fn 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
309/// Merge a refreshed `claudeAiOauth` into an existing credentials document
310/// (or a fresh `{}`), preserving any unknown top-level fields the Claude CLI
311/// keeps there (e.g. `mcpOAuth`). Pure so the merge is unit-testable without
312/// touching disk or the Keychain.
313fn 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
327/// Persist updated credentials to the source they were actually read from
328/// (see [`CredsSource`]), preserving any unknown top-level fields the Claude
329/// CLI might have added. Following the read source keeps a single shared
330/// source of truth with Claude Code — refreshing Keychain-read tokens into a
331/// stale shadow file would rotate the refresh token out from under it.
332pub 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
361/// Persist updated credentials to a file, preserving unknown top-level fields.
362pub 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    /// Like `write_creds`, but with no open handle on the file, so
383    /// `write_back`'s atomic rename-over-destination succeeds on Windows.
384    /// See [`crate::cache::closed_temp_file`].
385    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        // claudebar truncates `5000.0 → 5000`; we do the same.
409        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    // Linux-only: a missing file with no Keychain fallback is an I/O error,
464    // not a parse error. `read_from` is strict on every platform — explicit
465    // paths never consult the Keychain (issues #14/#15) — so this needs no
466    // macOS gate anymore.
467    #[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    // --- issue #15: default-location Keychain fallback selection ------------
475    // `read_default_with` takes the keychain reader as a closure, so these run
476    // hermetically on any platform — no real Keychain, no real $HOME.
477
478    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        // The v0.7.2 trusted-device shape (empty refreshToken, live
493        // accessToken) must NOT count as unusable — file stays authoritative.
494        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        // The regression this guards: `read_raw` mapped *every* `security`
532        // failure to Ok(None), so a locked login Keychain looked identical to
533        // "not logged in" and the user was told to run `claude` while their
534        // credentials sat there intact.
535        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        // The #15 scenario: stale zeroed file shadowing fresh Keychain creds.
550        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        // No keychain item → the file result stands, source stays File.
561        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        // Keychain item just as dead → same.
565        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        // Without a keychain rescue, the original parse error surfaces.
577        let err = read_default_with(&path, || Ok(None)).unwrap_err();
578        assert!(matches!(err, AppError::Credentials(_)));
579    }
580
581    // --- named accounts: config-dir-scoped Keychain wins over the file ------
582    // `read_named_with` takes the keychain reader as a closure, so these run
583    // hermetically on any platform — no real Keychain, no real $HOME.
584
585    #[test]
586    fn named_read_prefers_keychain_over_a_live_looking_file() {
587        // The production failure this order exists for: the file is a stale
588        // hand-made snapshot that still *looks* usable (tokens present) but
589        // whose refresh lineage has rotated away; the scoped Keychain item is
590        // what `claude` actually keeps alive.
591        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        // Linux layout / hand-managed file: no scoped Keychain item exists.
602        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        // Unparsable Keychain blob — same fallback.
616        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        // Same reasoning as the default account: a locked Keychain is not
632        // "no credentials", and silently reading a stale snapshot instead
633        // recreates exactly the half-dead fork this order prevents.
634        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        // An explicit target with a missing file is an I/O error even on a
646        // machine whose Keychain holds valid creds — resolve() routes Explicit
647        // through the strict read_from, which has no keychain path at all.
648        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        // The trailing two segments are stable across platforms; only the home
657        // prefix differs (resolved by directories::BaseDirs).
658        assert!(p.ends_with(std::path::Path::new(".claude").join(".credentials.json")));
659    }
660
661    // On Windows the home prefix is %USERPROFILE%, not $HOME — assert the
662    // resolver honors it so the credential file is found natively.
663    #[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        // directories::BaseDirs resolves the home via SHGetKnownFolderPath, which
669        // can differ from %USERPROFILE% in casing or path separator. Compare on a
670        // normalized basis (lowercased, backslashes) rather than Path::starts_with,
671        // which compares components case-sensitively even on Windows.
672        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        // None → fresh object.
711        let doc = merge_oauth(None, &new_oauth).unwrap();
712        assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
713        // Garbage / non-object → discarded, fresh object.
714        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        // Re-read & verify the unknown field survived.
739        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}