1use std::path::PathBuf;
2
3use super::{AuthFile, OAuthCredentials};
4
5pub fn auth_file_path() -> PathBuf {
7 crate::config::resolve_read_path("auth.json")
8}
9
10pub fn load_auth() -> std::result::Result<Option<AuthFile>, String> {
12 let path = auth_file_path();
13 if !path.exists() {
14 return Ok(None);
15 }
16 let content = std::fs::read_to_string(&path)
17 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
18 let auth: AuthFile = serde_json::from_str(&content)
19 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
20 Ok(Some(auth))
21}
22
23pub fn load_provider_auth(provider: &str) -> std::result::Result<Option<OAuthCredentials>, String> {
25 let path = auth_file_path();
26 if !path.exists() {
27 return Ok(None);
28 }
29 let content = std::fs::read_to_string(&path)
30 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
31 let value: serde_json::Value = serde_json::from_str(&content)
32 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
33 let Some(raw) = value.get(provider) else {
34 return Ok(None);
35 };
36 let creds: OAuthCredentials = serde_json::from_value(raw.clone())
37 .map_err(|e| format!("Failed to parse {} credential: {}", provider, e))?;
38 Ok(Some(creds))
39}
40
41pub fn save_auth(creds: &OAuthCredentials) -> std::result::Result<(), String> {
43 save_provider_auth("anthropic", creds)
44}
45
46pub fn save_provider_auth(
48 provider: &str,
49 creds: &OAuthCredentials,
50) -> std::result::Result<(), String> {
51 let path = crate::config::resolve_write_path("auth.json");
52 save_provider_auth_at(&path, provider, creds)
53}
54
55pub fn load_cloud_state(provider: &str) -> std::result::Result<Option<serde_json::Value>, String> {
57 let path = auth_file_path();
58 if !path.exists() {
59 return Ok(None);
60 }
61 let root: serde_json::Value = serde_json::from_str(
62 &std::fs::read_to_string(&path)
63 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?,
64 )
65 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
66 Ok(root
67 .get(provider)
68 .and_then(|v| v.get("cloud_state"))
69 .cloned())
70}
71
72pub fn save_cloud_state(
74 provider: &str,
75 state: &serde_json::Value,
76) -> std::result::Result<(), String> {
77 let path = crate::config::resolve_write_path("auth.json");
78 let mut fields = serde_json::Map::new();
79 fields.insert("type".into(), serde_json::Value::String("cloud".into()));
80 fields.insert("cloud_state".into(), state.clone());
81 save_provider_fields_at(&path, provider, &fields)
82}
83
84pub fn load_static_key(provider: &str) -> std::result::Result<Option<String>, String> {
90 load_static_key_at(&auth_file_path(), provider)
91}
92
93pub(crate) fn load_static_key_at(
94 path: &std::path::Path,
95 provider: &str,
96) -> std::result::Result<Option<String>, String> {
97 if !path.exists() {
98 return Ok(None);
99 }
100 let content = std::fs::read_to_string(path)
101 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
102 let value: serde_json::Value = serde_json::from_str(&content)
103 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
104 let Some(entry) = value.get(provider) else {
105 return Ok(None);
106 };
107 if entry.get("type").and_then(|t| t.as_str()) != Some("api_key") {
108 return Ok(None);
109 }
110 Ok(entry
111 .get("key")
112 .and_then(|k| k.as_str())
113 .filter(|k| !k.is_empty())
114 .map(str::to_string))
115}
116
117pub fn save_static_key(provider: &str, key: &str) -> std::result::Result<(), String> {
120 let path = crate::config::resolve_write_path("auth.json");
121 save_static_key_at(&path, provider, key)
122}
123
124pub(crate) fn save_static_key_at(
125 path: &std::path::Path,
126 provider: &str,
127 key: &str,
128) -> std::result::Result<(), String> {
129 let mut fields = serde_json::Map::new();
130 fields.insert(
131 "type".to_string(),
132 serde_json::Value::String("api_key".to_string()),
133 );
134 fields.insert(
135 "key".to_string(),
136 serde_json::Value::String(key.to_string()),
137 );
138 save_provider_fields_at(path, provider, &fields)
139}
140
141fn save_provider_auth_at(
145 path: &std::path::Path,
146 provider: &str,
147 creds: &OAuthCredentials,
148) -> std::result::Result<(), String> {
149 let encoded =
150 serde_json::to_value(creds).map_err(|e| format!("Failed to serialize auth: {}", e))?;
151 let fields = encoded
152 .as_object()
153 .ok_or_else(|| "credential did not serialize to an object".to_string())?;
154 save_provider_fields_at(path, provider, fields)
155}
156
157fn save_provider_fields_at(
160 path: &std::path::Path,
161 provider: &str,
162 fields: &serde_json::Map<String, serde_json::Value>,
163) -> std::result::Result<(), String> {
164 use fs4::fs_std::FileExt;
165
166 if let Some(parent) = path.parent() {
168 std::fs::create_dir_all(parent)
169 .map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
170 }
171
172 let lock_path = path.with_extension("json.lock");
177 let lock_file = std::fs::OpenOptions::new()
178 .create(true)
179 .truncate(true)
180 .write(true)
181 .open(&lock_path)
182 .map_err(|e| format!("Failed to open lock file {}: {}", lock_path.display(), e))?;
183 FileExt::lock_exclusive(&lock_file)
184 .map_err(|e| format!("Failed to lock {}: {}", lock_path.display(), e))?;
185
186 let mut root = if path.exists() {
187 let content = std::fs::read_to_string(path)
188 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
189 match serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&content) {
195 Ok(map) => map,
196 Err(e) => {
197 tracing::warn!(
198 path = %path.display(),
199 error = %e,
200 "auth.json could not be parsed as a JSON object; replacing with a fresh structure"
201 );
202 let ts = std::time::SystemTime::now()
204 .duration_since(std::time::UNIX_EPOCH)
205 .map(|d| d.as_secs())
206 .unwrap_or(0);
207 let backup = path.with_extension(format!("json.corrupt.{}", ts));
208 match std::fs::copy(path, &backup) {
209 Ok(_) => {
210 eprintln!(
211 "[warn] auth.json was corrupt and has been reset. Backup saved to: {}",
212 backup.display()
213 );
214 }
215 Err(copy_err) => {
216 eprintln!(
217 "[warn] auth.json was corrupt and has been reset, but backup failed: {}",
218 copy_err
219 );
220 }
221 }
222 serde_json::Map::new()
223 }
224 }
225 } else {
226 serde_json::Map::new()
227 };
228
229 let mut provider_value = root
233 .remove(provider)
234 .filter(serde_json::Value::is_object)
235 .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
236 if let Some(target) = provider_value.as_object_mut() {
237 for (key, value) in fields {
238 target.insert(key.clone(), value.clone());
239 }
240 }
241 root.insert(provider.to_string(), provider_value);
242
243 let json = serde_json::to_string_pretty(&root)
244 .map_err(|e| format!("Failed to serialize auth: {}", e))?;
245
246 let tmp_path = path.with_extension("json.tmp");
251 {
252 use std::io::Write;
253 let mut file = std::fs::OpenOptions::new()
254 .write(true)
255 .create(true)
256 .truncate(true)
257 .open(&tmp_path)
258 .map_err(|e| format!("Failed to create {}: {}", tmp_path.display(), e))?;
259
260 #[cfg(unix)]
261 {
262 use std::os::unix::fs::PermissionsExt;
263 let perms = std::fs::Permissions::from_mode(0o600);
264 file.set_permissions(perms).map_err(|e| {
265 format!("Failed to set permissions on {}: {}", tmp_path.display(), e)
266 })?;
267 }
268
269 file.write_all(json.as_bytes())
270 .map_err(|e| format!("Failed to write {}: {}", tmp_path.display(), e))?;
271 file.sync_all()
272 .map_err(|e| format!("Failed to fsync {}: {}", tmp_path.display(), e))?;
273 }
274
275 std::fs::rename(&tmp_path, path)
276 .map_err(|e| format!("Failed to atomically replace {}: {}", path.display(), e))?;
277
278 Ok(())
279}
280
281#[cfg(test)]
285pub(super) fn save_provider_auth_at_test_hook(
286 path: &std::path::Path,
287 provider: &str,
288 creds: &OAuthCredentials,
289) -> std::result::Result<(), String> {
290 save_provider_auth_at(path, provider, creds)
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 fn fresh_creds() -> OAuthCredentials {
298 OAuthCredentials {
299 auth_type: "oauth".to_string(),
300 refresh: "r".to_string(),
301 access: "a".to_string(),
302 expires: 1,
303 account_id: None,
304 }
305 }
306
307 #[test]
308 fn save_preserves_unknown_nested_provider_metadata() {
309 let dir = tempfile::tempdir().expect("tempdir");
310 let path = dir.path().join("auth.json");
311 std::fs::write(&path, r#"{"openai-codex":{"type":"oauth","refresh":"old","access":"old","expires":1,"metadata":{"tenant":"t1","flags":{"beta":true}}}}"#).unwrap();
312 save_provider_auth_at(&path, "openai-codex", &fresh_creds()).unwrap();
313 let value: serde_json::Value =
314 serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
315 assert_eq!(value["openai-codex"]["metadata"]["tenant"], "t1");
316 assert_eq!(value["openai-codex"]["metadata"]["flags"]["beta"], true);
317 assert_eq!(value["openai-codex"]["access"], "a");
318 }
319
320 #[test]
321 fn save_provider_auth_at_creates_file_when_absent() {
322 let dir = tempfile::tempdir().expect("tempdir");
323 let path = dir.path().join("auth.json");
324 save_provider_auth_at(&path, "openai-codex", &fresh_creds()).expect("save");
325 assert!(path.exists());
326 let content = std::fs::read_to_string(&path).unwrap();
327 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
328 assert!(parsed.get("openai-codex").is_some());
329 }
330
331 #[test]
332 fn save_provider_auth_at_preserves_other_providers() {
333 let dir = tempfile::tempdir().expect("tempdir");
334 let path = dir.path().join("auth.json");
335 std::fs::write(
336 &path,
337 r#"{"anthropic":{"type":"oauth","refresh":"r2","access":"a2","expires":2}}"#,
338 )
339 .unwrap();
340 save_provider_auth_at(&path, "openai-codex", &fresh_creds()).expect("save");
341 let content = std::fs::read_to_string(&path).unwrap();
342 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
343 assert!(
344 parsed.get("anthropic").is_some(),
345 "must keep anthropic entry"
346 );
347 assert!(parsed.get("openai-codex").is_some());
348 }
349
350 #[test]
351 fn save_provider_auth_at_recovers_from_corrupt_file() {
352 let dir = tempfile::tempdir().expect("tempdir");
357 let path = dir.path().join("auth.json");
358 std::fs::write(&path, "this is not json {{{").unwrap();
359 save_provider_auth_at(&path, "openai-codex", &fresh_creds())
360 .expect("save must succeed even on corrupt input");
361 let content = std::fs::read_to_string(&path).unwrap();
362 let parsed: serde_json::Value =
363 serde_json::from_str(&content).expect("file must now contain valid JSON");
364 assert!(parsed.get("openai-codex").is_some());
365 assert!(
366 parsed.get("anthropic").is_none(),
367 "corrupt fallback discards old (unrecoverable) entries"
368 );
369 }
370
371 #[test]
372 fn save_provider_auth_at_recovers_from_array_root() {
373 let dir = tempfile::tempdir().expect("tempdir");
376 let path = dir.path().join("auth.json");
377 std::fs::write(&path, "[1, 2, 3]").unwrap();
378 save_provider_auth_at(&path, "openai-codex", &fresh_creds())
379 .expect("save must succeed against non-object root");
380 let parsed: serde_json::Value =
381 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
382 assert!(parsed.is_object());
383 assert!(parsed.get("openai-codex").is_some());
384 }
385
386 #[test]
391 fn static_key_save_preserves_oauth_entries_and_roundtrips() {
392 let dir = tempfile::tempdir().expect("tempdir");
393 let path = dir.path().join("auth.json");
394 save_provider_auth_at(&path, "anthropic", &fresh_creds()).expect("save oauth");
395 save_static_key_at(&path, "groq", "gsk-secret-1").expect("save static key");
396
397 assert_eq!(
398 load_static_key_at(&path, "groq").unwrap().as_deref(),
399 Some("gsk-secret-1")
400 );
401 let parsed: serde_json::Value =
402 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
403 assert_eq!(
404 parsed["anthropic"]["refresh"], "r",
405 "OAuth entry must survive"
406 );
407 assert_eq!(parsed["groq"]["type"], "api_key");
408 }
409
410 #[test]
412 fn oauth_save_preserves_static_key_entries() {
413 let dir = tempfile::tempdir().expect("tempdir");
414 let path = dir.path().join("auth.json");
415 save_static_key_at(&path, "openrouter", "sk-or-1").expect("save static key");
416 save_provider_auth_at(&path, "openai-codex", &fresh_creds()).expect("save oauth");
417 assert_eq!(
418 load_static_key_at(&path, "openrouter").unwrap().as_deref(),
419 Some("sk-or-1")
420 );
421 }
422
423 #[test]
425 fn static_key_upsert_preserves_entry_metadata() {
426 let dir = tempfile::tempdir().expect("tempdir");
427 let path = dir.path().join("auth.json");
428 std::fs::write(
429 &path,
430 r#"{"groq":{"type":"api_key","key":"old","note":{"source":"migrated"}}}"#,
431 )
432 .unwrap();
433 save_static_key_at(&path, "groq", "new-key").expect("upsert");
434 let parsed: serde_json::Value =
435 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
436 assert_eq!(parsed["groq"]["key"], "new-key");
437 assert_eq!(parsed["groq"]["note"]["source"], "migrated");
438 }
439
440 #[test]
443 fn load_static_key_ignores_oauth_entries() {
444 let dir = tempfile::tempdir().expect("tempdir");
445 let path = dir.path().join("auth.json");
446 save_provider_auth_at(&path, "anthropic", &fresh_creds()).expect("save oauth");
447 assert_eq!(load_static_key_at(&path, "anthropic").unwrap(), None);
448 assert_eq!(load_static_key_at(&path, "missing").unwrap(), None);
449 }
450
451 #[test]
468 fn bug159_anthropic_login_preserves_existing_openai_codex() {
469 let dir = tempfile::tempdir().expect("tempdir");
470 let path = dir.path().join("auth.json");
471
472 let codex_creds = OAuthCredentials {
474 auth_type: "oauth".to_string(),
475 refresh: "codex-refresh".to_string(),
476 access: "codex-access".to_string(),
477 expires: 9_999_999_999_000,
478 account_id: Some("acct_codex_123".to_string()),
479 };
480 save_provider_auth_at(&path, "openai-codex", &codex_creds).expect("save openai-codex");
481
482 let anthropic_creds = OAuthCredentials {
484 auth_type: "oauth".to_string(),
485 refresh: "anth-refresh".to_string(),
486 access: "anth-access".to_string(),
487 expires: 9_999_999_999_000,
488 account_id: None,
489 };
490 save_provider_auth_at(&path, "anthropic", &anthropic_creds).expect("save anthropic");
491
492 let content = std::fs::read_to_string(&path).unwrap();
494 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
495
496 assert!(
497 parsed.get("anthropic").is_some(),
498 "anthropic credential must be present after login"
499 );
500 assert!(
501 parsed.get("openai-codex").is_some(),
502 "bug #159: openai-codex credential must NOT be wiped when logging in as anthropic"
503 );
504 assert_eq!(
505 parsed["openai-codex"]["refresh"].as_str(),
506 Some("codex-refresh"),
507 "openai-codex refresh token must be unchanged"
508 );
509 assert_eq!(
510 parsed["anthropic"]["refresh"].as_str(),
511 Some("anth-refresh"),
512 "anthropic refresh token must be correctly saved"
513 );
514 }
515
516 #[test]
519 fn bug159_openai_codex_login_preserves_existing_anthropic() {
520 let dir = tempfile::tempdir().expect("tempdir");
521 let path = dir.path().join("auth.json");
522
523 let anthropic_creds = OAuthCredentials {
525 auth_type: "oauth".to_string(),
526 refresh: "anth-refresh-first".to_string(),
527 access: "anth-access-first".to_string(),
528 expires: 9_999_999_999_000,
529 account_id: None,
530 };
531 save_provider_auth_at(&path, "anthropic", &anthropic_creds).expect("save anthropic");
532
533 let codex_creds = OAuthCredentials {
535 auth_type: "oauth".to_string(),
536 refresh: "codex-refresh-second".to_string(),
537 access: "codex-access-second".to_string(),
538 expires: 9_999_999_999_000,
539 account_id: Some("acct_456".to_string()),
540 };
541 save_provider_auth_at(&path, "openai-codex", &codex_creds).expect("save openai-codex");
542
543 let content = std::fs::read_to_string(&path).unwrap();
544 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
545
546 assert!(
547 parsed.get("anthropic").is_some(),
548 "anthropic credential must survive openai-codex login"
549 );
550 assert!(
551 parsed.get("openai-codex").is_some(),
552 "openai-codex credential must be present"
553 );
554 assert_eq!(
555 parsed["anthropic"]["refresh"].as_str(),
556 Some("anth-refresh-first"),
557 "anthropic refresh token must be unchanged"
558 );
559 }
560
561 #[test]
564 fn bug159_third_provider_login_preserves_both_existing_providers() {
565 let dir = tempfile::tempdir().expect("tempdir");
566 let path = dir.path().join("auth.json");
567
568 save_provider_auth_at(
569 &path,
570 "anthropic",
571 &OAuthCredentials {
572 auth_type: "oauth".to_string(),
573 refresh: "anth-r".to_string(),
574 access: "anth-a".to_string(),
575 expires: 1,
576 account_id: None,
577 },
578 )
579 .expect("save anthropic");
580
581 save_provider_auth_at(
582 &path,
583 "openai-codex",
584 &OAuthCredentials {
585 auth_type: "oauth".to_string(),
586 refresh: "codex-r".to_string(),
587 access: "codex-a".to_string(),
588 expires: 1,
589 account_id: Some("acct_codex".to_string()),
590 },
591 )
592 .expect("save openai-codex");
593
594 save_provider_auth_at(
595 &path,
596 "future-provider",
597 &OAuthCredentials {
598 auth_type: "oauth".to_string(),
599 refresh: "future-r".to_string(),
600 access: "future-a".to_string(),
601 expires: 1,
602 account_id: None,
603 },
604 )
605 .expect("save future-provider");
606
607 let content = std::fs::read_to_string(&path).unwrap();
608 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
609
610 assert!(parsed.get("anthropic").is_some(), "anthropic must survive");
611 assert!(
612 parsed.get("openai-codex").is_some(),
613 "openai-codex must survive"
614 );
615 assert!(
616 parsed.get("future-provider").is_some(),
617 "future-provider must be present"
618 );
619 }
620
621 #[test]
624 fn bug159_same_provider_login_upserts_in_place() {
625 let dir = tempfile::tempdir().expect("tempdir");
626 let path = dir.path().join("auth.json");
627
628 save_provider_auth_at(
629 &path,
630 "anthropic",
631 &OAuthCredentials {
632 auth_type: "oauth".to_string(),
633 refresh: "old-refresh".to_string(),
634 access: "old-access".to_string(),
635 expires: 1,
636 account_id: None,
637 },
638 )
639 .expect("initial save");
640
641 save_provider_auth_at(
642 &path,
643 "anthropic",
644 &OAuthCredentials {
645 auth_type: "oauth".to_string(),
646 refresh: "new-refresh".to_string(),
647 access: "new-access".to_string(),
648 expires: 2,
649 account_id: None,
650 },
651 )
652 .expect("upsert save");
653
654 let content = std::fs::read_to_string(&path).unwrap();
655 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
656 let obj = parsed.as_object().unwrap();
657
658 assert_eq!(obj.len(), 1, "only one anthropic entry — no duplicates");
659 assert_eq!(
660 parsed["anthropic"]["refresh"].as_str(),
661 Some("new-refresh"),
662 "credential must be updated to latest value"
663 );
664 }
665}