1use 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#[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 #[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 #[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 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 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
102pub 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 #[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
128fn 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
137fn 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
155pub 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 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 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 #[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 assert!(p.ends_with(std::path::Path::new(".claude").join(".credentials.json")));
287 }
288
289 #[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 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 let doc = merge_oauth(None, &new_oauth).unwrap();
340 assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
341 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 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}