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}
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/// Where credentials were actually read from. Write-backs must follow this —
158/// refreshing tokens read from the Keychain into a stale file (or vice versa)
159/// would fork the credential state Claude Code depends on.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum CredsSource {
162    File(PathBuf),
163    /// Only produced on macOS in production (the login Keychain item
164    /// `Claude Code-credentials`).
165    Keychain,
166    /// Only produced on macOS in production: a named account's
167    /// `CLAUDE_CONFIG_DIR`-scoped Keychain item (`Claude
168    /// Code-credentials-<hash>`), keyed by the account's config dir so
169    /// write-backs land in the same per-account item they were read from.
170    NamedKeychain(PathBuf),
171}
172
173/// Credentials that can't possibly authenticate anything: no access token, no
174/// refresh token, no future expiry. This is the #15 predicate — deliberately
175/// narrow so the v0.7.2 trusted-device shape (empty `refreshToken` but a live
176/// `accessToken`) keeps its file-first behavior.
177pub 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
183/// Resolve a [`CredsTarget`] to actual credentials + the source they came
184/// from. `Explicit` is a strict [`read_from`]; `Default` adds the macOS
185/// Keychain fallback.
186pub 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
204/// Default-location read with an injectable Keychain reader, so the fallback
205/// selection is unit-testable on any platform (the hermeticity invariant —
206/// tests must never touch a real Keychain). Decision table:
207///
208/// | file state          | keychain        | outcome                    |
209/// |---------------------|-----------------|----------------------------|
210/// | usable              | (not consulted*)| file                       |
211/// | missing             | usable          | keychain                   |
212/// | missing             | absent          | original I/O error         |
213/// | unusable (#15)      | usable          | keychain                   |
214/// | unusable (#15)      | absent          | file result, unchanged     |
215/// | unparsable JSON     | usable          | keychain                   |
216/// | unparsable JSON     | absent          | original parse error       |
217/// | any                 | **unreadable**  | the Keychain error         |
218///
219/// The last row matters: a *locked* login Keychain or a denied ACL is not the
220/// same as "no credentials". Reporting it as absent surfaced a "run `claude`"
221/// message and sent users to re-authenticate while their credentials were
222/// sitting there intact, so that failure now wins over the file's own error.
223///
224/// *usable file short-circuits — no `security(1)` subprocess on the happy path.
225fn 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        // Missing, unusable, or unparsable — see if the Keychain has better.
235        _ => 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                // Keychain no better than the file — surface the file outcome.
239                _ => 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
246/// Named-account read: the account's `CLAUDE_CONFIG_DIR`-scoped Keychain item
247/// wins over the file. On macOS the `claude` CLI *always* writes logins to the
248/// Keychain — `CLAUDE_CONFIG_DIR=<dir> claude` lands in the dir-scoped item,
249/// never in `<dir>/.credentials.json` — so a file there is at best a hand-made
250/// snapshot. Snapshots die: their refresh-token lineage rotates away the next
251/// time the *real* holder refreshes, and the copy 401s within hours (observed
252/// in production; that failure is what motivated this order). Decision table:
253///
254/// | keychain item              | file             | outcome            |
255/// |----------------------------|------------------|--------------------|
256/// | usable                     | (not consulted)  | keychain           |
257/// | absent/unusable/unparsable | readable         | file               |
258/// | absent/unusable/unparsable | missing/broken   | the file's error   |
259/// | **unreadable** (locked/ACL)| any              | the Keychain error |
260///
261/// The unreadable row follows [`read_default_with`]'s reasoning: a locked
262/// Keychain is not "no credentials", and silently dropping to a stale file
263/// snapshot would produce exactly the confusing half-dead state this order
264/// exists to prevent. On non-macOS the injected reader returns `None`, so the
265/// file is simply read strictly — the Linux layout is file-only.
266fn read_named_with(
267    path: &Path,
268    config_dir: &Path,
269    keychain_read: impl Fn(&Path) -> Result<Option<String>>,
270) -> Result<(CredentialsFile, CredsSource)> {
271    // A present-but-unparsable/unusable item is no better than the file, so
272    // the whole chain failing falls through to the strict file read.
273    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
282/// Parse a credentials JSON blob from any source (`source` only labels errors).
283fn 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
291/// Merge a refreshed `claudeAiOauth` into an existing credentials document
292/// (or a fresh `{}`), preserving any unknown top-level fields the Claude CLI
293/// keeps there (e.g. `mcpOAuth`). Pure so the merge is unit-testable without
294/// touching disk or the Keychain.
295fn 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
309/// Persist updated credentials to the source they were actually read from
310/// (see [`CredsSource`]), preserving any unknown top-level fields the Claude
311/// CLI might have added. Following the read source keeps a single shared
312/// source of truth with Claude Code — refreshing Keychain-read tokens into a
313/// stale shadow file would rotate the refresh token out from under it.
314pub 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
342/// Persist updated credentials to a file, preserving unknown top-level fields.
343pub 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    /// Like `write_creds`, but with no open handle on the file, so
364    /// `write_back`'s atomic rename-over-destination succeeds on Windows.
365    /// See [`crate::cache::closed_temp_file`].
366    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        // claudebar truncates `5000.0 → 5000`; we do the same.
390        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    // Linux-only: a missing file with no Keychain fallback is an I/O error,
445    // not a parse error. `read_from` is strict on every platform — explicit
446    // paths never consult the Keychain (issues #14/#15) — so this needs no
447    // macOS gate anymore.
448    #[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    // --- issue #15: default-location Keychain fallback selection ------------
456    // `read_default_with` takes the keychain reader as a closure, so these run
457    // hermetically on any platform — no real Keychain, no real $HOME.
458
459    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        // The v0.7.2 trusted-device shape (empty refreshToken, live
474        // accessToken) must NOT count as unusable — file stays authoritative.
475        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        // The regression this guards: `read_raw` mapped *every* `security`
513        // failure to Ok(None), so a locked login Keychain looked identical to
514        // "not logged in" and the user was told to run `claude` while their
515        // credentials sat there intact.
516        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        // The #15 scenario: stale zeroed file shadowing fresh Keychain creds.
531        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        // No keychain item → the file result stands, source stays File.
542        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        // Keychain item just as dead → same.
546        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        // Without a keychain rescue, the original parse error surfaces.
558        let err = read_default_with(&path, || Ok(None)).unwrap_err();
559        assert!(matches!(err, AppError::Credentials(_)));
560    }
561
562    // --- named accounts: config-dir-scoped Keychain wins over the file ------
563    // `read_named_with` takes the keychain reader as a closure, so these run
564    // hermetically on any platform — no real Keychain, no real $HOME.
565
566    #[test]
567    fn named_read_prefers_keychain_over_a_live_looking_file() {
568        // The production failure this order exists for: the file is a stale
569        // hand-made snapshot that still *looks* usable (tokens present) but
570        // whose refresh lineage has rotated away; the scoped Keychain item is
571        // what `claude` actually keeps alive.
572        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        // Linux layout / hand-managed file: no scoped Keychain item exists.
583        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        // Unparsable Keychain blob — same fallback.
597        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        // Same reasoning as the default account: a locked Keychain is not
613        // "no credentials", and silently reading a stale snapshot instead
614        // recreates exactly the half-dead fork this order prevents.
615        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        // An explicit target with a missing file is an I/O error even on a
627        // machine whose Keychain holds valid creds — resolve() routes Explicit
628        // through the strict read_from, which has no keychain path at all.
629        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        // The trailing two segments are stable across platforms; only the home
638        // prefix differs (resolved by directories::BaseDirs).
639        assert!(p.ends_with(std::path::Path::new(".claude").join(".credentials.json")));
640    }
641
642    // On Windows the home prefix is %USERPROFILE%, not $HOME — assert the
643    // resolver honors it so the credential file is found natively.
644    #[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        // directories::BaseDirs resolves the home via SHGetKnownFolderPath, which
650        // can differ from %USERPROFILE% in casing or path separator. Compare on a
651        // normalized basis (lowercased, backslashes) rather than Path::starts_with,
652        // which compares components case-sensitively even on Windows.
653        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        // None → fresh object.
692        let doc = merge_oauth(None, &new_oauth).unwrap();
693        assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
694        // Garbage / non-object → discarded, fresh object.
695        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        // Re-read & verify the unknown field survived.
720        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}