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. When the file is absent we transom
6//! over to [`keychain`] so subscription usage works there too.
7
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use crate::cache::atomic_write;
13use crate::error::{AppError, Result};
14
15#[cfg(target_os = "macos")]
16use super::keychain;
17
18/// Disk shape (matches claudebar's jq paths).
19#[derive(Debug, Clone, Deserialize, Serialize)]
20pub struct CredentialsFile {
21    #[serde(rename = "claudeAiOauth")]
22    pub claude_ai_oauth: OauthCreds,
23}
24
25#[derive(Debug, Clone, Deserialize, Serialize)]
26pub struct OauthCreds {
27    #[serde(rename = "accessToken")]
28    pub access_token: String,
29    #[serde(rename = "refreshToken")]
30    pub refresh_token: String,
31    /// Unix epoch in **milliseconds** (claudebar:445 multiplies seconds × 1000).
32    /// May arrive as a float in the wild — claudebar truncates with `%%.*`,
33    /// so we accept both.
34    #[serde(rename = "expiresAt", deserialize_with = "de_ms_epoch")]
35    pub expires_at_ms: i64,
36    #[serde(rename = "subscriptionType", default)]
37    pub subscription_type: String,
38    #[serde(rename = "rateLimitTier", default)]
39    pub rate_limit_tier: String,
40    /// Optional `scopes` array — preserved through round-trips so we don't
41    /// drop information when we write back after a refresh.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub scopes: Option<serde_json::Value>,
44}
45
46fn de_ms_epoch<'de, D>(d: D) -> std::result::Result<i64, D::Error>
47where
48    D: serde::Deserializer<'de>,
49{
50    // Accept int or float — float values like 5000.0 are truncated.
51    let v = serde_json::Value::deserialize(d)?;
52    match v {
53        serde_json::Value::Number(n) => {
54            if let Some(i) = n.as_i64() {
55                Ok(i)
56            } else if let Some(f) = n.as_f64() {
57                Ok(f as i64)
58            } else {
59                Err(serde::de::Error::custom("expiresAt not numeric"))
60            }
61        }
62        _ => Err(serde::de::Error::custom("expiresAt must be a number")),
63    }
64}
65
66impl OauthCreds {
67    /// Plan label rendered the way claudebar does (claudebar:547-550):
68    ///   "${sub_type^} [5x|20x]" (first letter capitalized, optional tier suffix).
69    pub fn plan_label(&self) -> String {
70        let mut name = capitalize_first(&self.subscription_type);
71        if name.is_empty() {
72            name = "Unknown".into();
73        }
74        if self.rate_limit_tier.contains("5x") {
75            name.push_str(" 5x");
76        } else if self.rate_limit_tier.contains("20x") {
77            name.push_str(" 20x");
78        }
79        name
80    }
81
82    pub fn expires_at_secs(&self) -> i64 {
83        self.expires_at_ms / 1000
84    }
85}
86
87fn capitalize_first(s: &str) -> String {
88    let mut chars = s.chars();
89    match chars.next() {
90        Some(first) => {
91            let mut out = String::with_capacity(s.len());
92            for c in first.to_uppercase() {
93                out.push(c);
94            }
95            out.push_str(chars.as_str());
96            out
97        }
98        None => String::new(),
99    }
100}
101
102/// Default location: `~/.claude/.credentials.json` (Unix/macOS) or
103/// `%USERPROFILE%\.claude\.credentials.json` (Windows).
104///
105/// Home is resolved through [`crate::cache::home_dir`] so every platform's
106/// convention is honored in one place.
107pub fn default_path() -> Result<PathBuf> {
108    Ok(crate::cache::home_dir()?
109        .join(".claude")
110        .join(".credentials.json"))
111}
112
113pub fn read_from(path: &Path) -> Result<CredentialsFile> {
114    match std::fs::read_to_string(path) {
115        Ok(raw) => parse(&raw, &path.display().to_string()),
116        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
117            // No file — on macOS the credentials usually live in the Keychain.
118            #[cfg(target_os = "macos")]
119            if let Some(raw) = keychain::read_raw()? {
120                return parse(&raw, "macOS Keychain (Claude Code-credentials)");
121            }
122            Err(AppError::io_at(path, e))
123        }
124        Err(e) => Err(AppError::io_at(path, e)),
125    }
126}
127
128/// Parse a credentials JSON blob from any source (`source` only labels errors).
129fn parse(raw: &str, source: &str) -> Result<CredentialsFile> {
130    serde_json::from_str(raw).map_err(|e| {
131        AppError::Credentials(format!(
132            "could not parse {source}: {e}. Run `claude` to re-authenticate."
133        ))
134    })
135}
136
137/// Merge a refreshed `claudeAiOauth` into an existing credentials document
138/// (or a fresh `{}`), preserving any unknown top-level fields the Claude CLI
139/// keeps there (e.g. `mcpOAuth`). Pure so the merge is unit-testable without
140/// touching disk or the Keychain.
141fn merge_oauth(existing: Option<&str>, new_oauth: &OauthCreds) -> Result<serde_json::Value> {
142    let mut doc: serde_json::Value = existing
143        .and_then(|s| serde_json::from_str(s).ok())
144        .unwrap_or_else(|| serde_json::json!({}));
145    if !doc.is_object() {
146        doc = serde_json::json!({});
147    }
148    doc.as_object_mut().expect("just ensured object").insert(
149        "claudeAiOauth".into(),
150        serde_json::to_value(new_oauth).map_err(AppError::Json)?,
151    );
152    Ok(doc)
153}
154
155/// Persist updated credentials, preserving any unknown top-level fields the
156/// Claude CLI might have added. Writes back to wherever the creds actually
157/// live: the file if present, otherwise (macOS) the Keychain item — keeping a
158/// single shared source of truth with Claude Code instead of forking a stale
159/// copy and rotating the refresh token out from under it.
160pub fn write_back(path: &Path, new_oauth: &OauthCreds) -> Result<()> {
161    #[cfg(target_os = "macos")]
162    if !path.exists() {
163        if let Some(existing) = keychain::read_raw()? {
164            let doc = merge_oauth(Some(&existing), new_oauth)?;
165            let json = serde_json::to_string(&doc).map_err(AppError::Json)?;
166            return keychain::write_raw(&json);
167        }
168    }
169
170    let existing = std::fs::read_to_string(path).ok();
171    let doc = merge_oauth(existing.as_deref(), new_oauth)?;
172    let bytes = serde_json::to_vec_pretty(&doc).map_err(AppError::Json)?;
173    atomic_write(path, &bytes)
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use std::io::Write;
180    use tempfile::{NamedTempFile, TempDir};
181
182    fn write_creds(s: &str) -> NamedTempFile {
183        let mut f = NamedTempFile::new().unwrap();
184        f.write_all(s.as_bytes()).unwrap();
185        f.flush().unwrap();
186        f
187    }
188
189    /// Like `write_creds`, but with no open handle on the file, so
190    /// `write_back`'s atomic rename-over-destination succeeds on Windows.
191    /// See [`crate::cache::closed_temp_file`].
192    fn write_creds_closed(s: &str) -> (TempDir, std::path::PathBuf) {
193        crate::cache::closed_temp_file("credentials.json", Some(s))
194    }
195
196    #[test]
197    fn parses_canonical_shape() {
198        let f = write_creds(
199            r#"{"claudeAiOauth":{
200                "accessToken":"AT",
201                "refreshToken":"RT",
202                "expiresAt": 1735000000000,
203                "subscriptionType":"max",
204                "rateLimitTier":"default_claude_max_5x"
205            }}"#,
206        );
207        let creds = read_from(f.path()).unwrap();
208        assert_eq!(creds.claude_ai_oauth.access_token, "AT");
209        assert_eq!(creds.claude_ai_oauth.expires_at_ms, 1735000000000);
210        assert_eq!(creds.claude_ai_oauth.plan_label(), "Max 5x");
211    }
212
213    #[test]
214    fn accepts_float_expires_at() {
215        // claudebar truncates `5000.0 → 5000`; we do the same.
216        let f = write_creds(
217            r#"{"claudeAiOauth":{
218                "accessToken":"A","refreshToken":"R",
219                "expiresAt": 5000.0,
220                "subscriptionType":"pro","rateLimitTier":""
221            }}"#,
222        );
223        let creds = read_from(f.path()).unwrap();
224        assert_eq!(creds.claude_ai_oauth.expires_at_ms, 5000);
225    }
226
227    #[test]
228    fn plan_label_pro_no_tier() {
229        let f = write_creds(
230            r#"{"claudeAiOauth":{
231                "accessToken":"A","refreshToken":"R","expiresAt": 0,
232                "subscriptionType":"pro","rateLimitTier":""
233            }}"#,
234        );
235        let creds = read_from(f.path()).unwrap();
236        assert_eq!(creds.claude_ai_oauth.plan_label(), "Pro");
237    }
238
239    #[test]
240    fn plan_label_max_20x() {
241        let f = write_creds(
242            r#"{"claudeAiOauth":{
243                "accessToken":"A","refreshToken":"R","expiresAt": 0,
244                "subscriptionType":"max","rateLimitTier":"default_claude_max_20x"
245            }}"#,
246        );
247        let creds = read_from(f.path()).unwrap();
248        assert_eq!(creds.claude_ai_oauth.plan_label(), "Max 20x");
249    }
250
251    #[test]
252    fn plan_label_empty_subscription_falls_back() {
253        let f = write_creds(
254            r#"{"claudeAiOauth":{
255                "accessToken":"A","refreshToken":"R","expiresAt": 0,
256                "subscriptionType":"","rateLimitTier":""
257            }}"#,
258        );
259        let creds = read_from(f.path()).unwrap();
260        assert_eq!(creds.claude_ai_oauth.plan_label(), "Unknown");
261    }
262
263    #[test]
264    fn malformed_file_returns_credentials_error() {
265        let f = write_creds("not json");
266        let err = read_from(f.path()).unwrap_err();
267        assert!(matches!(err, AppError::Credentials(_)));
268    }
269
270    // Linux-only: a missing file with no Keychain fallback is an I/O error,
271    // not a parse error. Gated off macOS so we never read the developer's real
272    // Keychain item (which would both flake and surface a live token).
273    #[cfg(not(target_os = "macos"))]
274    #[test]
275    fn read_from_missing_file_is_io_error() {
276        let path = std::path::Path::new("/nonexistent/ai-usagebar/.credentials.json");
277        let err = read_from(path).unwrap_err();
278        assert!(matches!(err, AppError::Io { .. }));
279    }
280
281    #[test]
282    fn default_path_ends_with_claude_credentials() {
283        let p = default_path().unwrap();
284        // The trailing two segments are stable across platforms; only the home
285        // prefix differs (resolved by directories::BaseDirs).
286        assert!(p.ends_with(std::path::Path::new(".claude").join(".credentials.json")));
287    }
288
289    // On Windows the home prefix is %USERPROFILE%, not $HOME — assert the
290    // resolver honors it so the credential file is found natively.
291    #[cfg(windows)]
292    #[test]
293    fn default_path_uses_userprofile_on_windows() {
294        let p = default_path().unwrap();
295        let userprofile = std::env::var("USERPROFILE").expect("USERPROFILE set on Windows");
296        // directories::BaseDirs resolves the home via SHGetKnownFolderPath, which
297        // can differ from %USERPROFILE% in casing or path separator. Compare on a
298        // normalized basis (lowercased, backslashes) rather than Path::starts_with,
299        // which compares components case-sensitively even on Windows.
300        let norm = |s: &str| s.to_lowercase().replace('/', "\\");
301        let p_norm = norm(&p.to_string_lossy());
302        let up_norm = norm(&userprofile);
303        assert!(
304            p_norm.starts_with(up_norm.as_str()),
305            "{} should live under {}",
306            p.display(),
307            userprofile
308        );
309    }
310
311    #[test]
312    fn merge_oauth_preserves_unknown_top_level_fields() {
313        let existing = r#"{"claudeAiOauth":{"accessToken":"OLD"},"mcpOAuth":{"x":1}}"#;
314        let new_oauth = OauthCreds {
315            access_token: "NEW".into(),
316            refresh_token: "RT".into(),
317            expires_at_ms: 99,
318            subscription_type: "max".into(),
319            rate_limit_tier: "".into(),
320            scopes: None,
321        };
322        let doc = merge_oauth(Some(existing), &new_oauth).unwrap();
323        assert_eq!(doc["mcpOAuth"]["x"], 1);
324        assert_eq!(doc["claudeAiOauth"]["accessToken"], "NEW");
325        assert_eq!(doc["claudeAiOauth"]["expiresAt"], 99);
326    }
327
328    #[test]
329    fn merge_oauth_handles_empty_and_non_object_input() {
330        let new_oauth = OauthCreds {
331            access_token: "A".into(),
332            refresh_token: "R".into(),
333            expires_at_ms: 0,
334            subscription_type: "pro".into(),
335            rate_limit_tier: "".into(),
336            scopes: None,
337        };
338        // None → fresh object.
339        let doc = merge_oauth(None, &new_oauth).unwrap();
340        assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
341        // Garbage / non-object → discarded, fresh object.
342        let doc = merge_oauth(Some("not json"), &new_oauth).unwrap();
343        assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
344        let doc = merge_oauth(Some("[1,2,3]"), &new_oauth).unwrap();
345        assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
346    }
347
348    #[test]
349    fn write_back_round_trips_and_preserves_unknown_fields() {
350        let (_dir, path) = write_creds_closed(
351            r#"{"claudeAiOauth":{
352                "accessToken":"OLD","refreshToken":"OLD","expiresAt": 0,
353                "subscriptionType":"pro","rateLimitTier":""
354            },"someOtherField":"keep me"}"#,
355        );
356        let creds = read_from(&path).unwrap();
357        let new_oauth = OauthCreds {
358            access_token: "NEW".into(),
359            refresh_token: "NEW_RT".into(),
360            expires_at_ms: 1234,
361            subscription_type: "pro".into(),
362            rate_limit_tier: "".into(),
363            scopes: creds.claude_ai_oauth.scopes.clone(),
364        };
365        write_back(&path, &new_oauth).unwrap();
366        // Re-read & verify the unknown field survived.
367        let raw = std::fs::read_to_string(&path).unwrap();
368        let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
369        assert_eq!(v["someOtherField"], "keep me");
370        assert_eq!(v["claudeAiOauth"]["accessToken"], "NEW");
371        assert_eq!(v["claudeAiOauth"]["expiresAt"], 1234);
372    }
373}