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`, config `credentials_path`, or a named account's file —
135    /// never consults the Keychain.
136    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/// Where credentials were actually read from. Write-backs must follow this —
148/// refreshing tokens read from the Keychain into a stale file (or vice versa)
149/// would fork the credential state Claude Code depends on.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum CredsSource {
152    File(PathBuf),
153    /// Only produced on macOS in production (the login Keychain item
154    /// `Claude Code-credentials`).
155    Keychain,
156}
157
158/// Credentials that can't possibly authenticate anything: no access token, no
159/// refresh token, no future expiry. This is the #15 predicate — deliberately
160/// narrow so the v0.7.2 trusted-device shape (empty `refreshToken` but a live
161/// `accessToken`) keeps its file-first behavior.
162pub 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
168/// Resolve a [`CredsTarget`] to actual credentials + the source they came
169/// from. `Explicit` is a strict [`read_from`]; `Default` adds the macOS
170/// Keychain fallback.
171pub 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
183/// Default-location read with an injectable Keychain reader, so the fallback
184/// selection is unit-testable on any platform (the hermeticity invariant —
185/// tests must never touch a real Keychain). Decision table:
186///
187/// | file state          | keychain        | outcome                    |
188/// |---------------------|-----------------|----------------------------|
189/// | usable              | (not consulted*)| file                       |
190/// | missing             | usable          | keychain                   |
191/// | missing             | absent          | original I/O error         |
192/// | unusable (#15)      | usable          | keychain                   |
193/// | unusable (#15)      | absent/broken   | file result, unchanged     |
194/// | unparsable JSON     | usable          | keychain                   |
195/// | unparsable JSON     | absent/broken   | original parse error       |
196///
197/// *usable file short-circuits — no `security(1)` subprocess on the happy path.
198fn 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        // Missing, unusable, or unparsable — see if the Keychain has better.
208        _ => 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                // Keychain no better than the file — surface the file outcome.
212                _ => 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
219/// Parse a credentials JSON blob from any source (`source` only labels errors).
220fn 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
228/// Merge a refreshed `claudeAiOauth` into an existing credentials document
229/// (or a fresh `{}`), preserving any unknown top-level fields the Claude CLI
230/// keeps there (e.g. `mcpOAuth`). Pure so the merge is unit-testable without
231/// touching disk or the Keychain.
232fn 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
246/// Persist updated credentials to the source they were actually read from
247/// (see [`CredsSource`]), preserving any unknown top-level fields the Claude
248/// CLI might have added. Following the read source keeps a single shared
249/// source of truth with Claude Code — refreshing Keychain-read tokens into a
250/// stale shadow file would rotate the refresh token out from under it.
251pub 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
268/// Persist updated credentials to a file, preserving unknown top-level fields.
269pub 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    /// Like `write_creds`, but with no open handle on the file, so
290    /// `write_back`'s atomic rename-over-destination succeeds on Windows.
291    /// See [`crate::cache::closed_temp_file`].
292    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        // claudebar truncates `5000.0 → 5000`; we do the same.
316        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    // Linux-only: a missing file with no Keychain fallback is an I/O error,
371    // not a parse error. `read_from` is strict on every platform — explicit
372    // paths never consult the Keychain (issues #14/#15) — so this needs no
373    // macOS gate anymore.
374    #[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    // --- issue #15: default-location Keychain fallback selection ------------
382    // `read_default_with` takes the keychain reader as a closure, so these run
383    // hermetically on any platform — no real Keychain, no real $HOME.
384
385    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        // The v0.7.2 trusted-device shape (empty refreshToken, live
400        // accessToken) must NOT count as unusable — file stays authoritative.
401        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        // The #15 scenario: stale zeroed file shadowing fresh Keychain creds.
439        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        // No keychain item → the file result stands, source stays File.
450        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        // Keychain item just as dead → same.
454        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        // Without a keychain rescue, the original parse error surfaces.
466        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        // An explicit target with a missing file is an I/O error even on a
473        // machine whose Keychain holds valid creds — resolve() routes Explicit
474        // through the strict read_from, which has no keychain path at all.
475        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        // The trailing two segments are stable across platforms; only the home
484        // prefix differs (resolved by directories::BaseDirs).
485        assert!(p.ends_with(std::path::Path::new(".claude").join(".credentials.json")));
486    }
487
488    // On Windows the home prefix is %USERPROFILE%, not $HOME — assert the
489    // resolver honors it so the credential file is found natively.
490    #[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        // directories::BaseDirs resolves the home via SHGetKnownFolderPath, which
496        // can differ from %USERPROFILE% in casing or path separator. Compare on a
497        // normalized basis (lowercased, backslashes) rather than Path::starts_with,
498        // which compares components case-sensitively even on Windows.
499        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        // None → fresh object.
538        let doc = merge_oauth(None, &new_oauth).unwrap();
539        assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
540        // Garbage / non-object → discarded, fresh object.
541        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        // Re-read & verify the unknown field survived.
566        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}