1use std::path::{Path, PathBuf};
2
3use super::account::{Account, AccountSummary, CredentialRef};
4use super::provider::OAuthProviderId;
5use super::{AuthFile, OAuthCredentials};
6
7pub fn auth_file_path() -> PathBuf {
9 crate::config::resolve_read_path("auth.json")
10}
11
12pub fn load_auth() -> std::result::Result<Option<AuthFile>, String> {
14 let path = auth_file_path();
15 if !path.exists() {
16 return Ok(None);
17 }
18 let content = std::fs::read_to_string(&path)
19 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
20 let auth: AuthFile = serde_json::from_str(&content)
21 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
22 Ok(Some(auth))
23}
24
25pub fn load_provider_auth(provider: &str) -> std::result::Result<Option<OAuthCredentials>, String> {
30 load_provider_auth_at(&auth_file_path(), provider)
31}
32
33pub(crate) fn load_provider_auth_at(
34 path: &Path,
35 provider: &str,
36) -> std::result::Result<Option<OAuthCredentials>, String> {
37 load_provider_auth_typed_at(path, provider).map_err(String::from)
38}
39
40pub(crate) fn load_provider_auth_typed_at(
43 path: &Path,
44 provider: &str,
45) -> std::result::Result<Option<OAuthCredentials>, StoreError> {
46 if !path.exists() {
47 return Ok(None);
48 }
49 let content = std::fs::read_to_string(path)
50 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
51 let value: serde_json::Value =
52 serde_json::from_str(&content).map_err(|e| corrupt_store(path, &e))?;
53 let Some(raw) = value.get(provider) else {
54 return Ok(None);
55 };
56 let creds: OAuthCredentials = serde_json::from_value(raw.clone())
57 .map_err(|e| format!("Failed to parse {} credential: {}", provider, e))?;
58 Ok(Some(creds))
59}
60
61pub fn load_credential(
65 cred: &CredentialRef,
66) -> std::result::Result<Option<OAuthCredentials>, String> {
67 load_provider_auth(&cred.storage_key())
68}
69
70pub fn save_credential(
76 cred: &CredentialRef,
77 creds: &OAuthCredentials,
78) -> std::result::Result<(), String> {
79 let path = crate::config::resolve_write_path("auth.json");
80 save_credential_at(&path, cred, creds)
81}
82
83pub(crate) fn save_credential_at(
84 path: &Path,
85 cred: &CredentialRef,
86 creds: &OAuthCredentials,
87) -> std::result::Result<(), String> {
88 let encoded =
89 serde_json::to_value(creds).map_err(|e| format!("Failed to serialize auth: {}", e))?;
90 let fields = encoded
91 .as_object()
92 .cloned()
93 .ok_or_else(|| "credential did not serialize to an object".to_string())?;
94 let key = cred.storage_key();
95 with_locked_root(path, false, |root| {
96 let mut slot = root
97 .remove(&key)
98 .filter(serde_json::Value::is_object)
99 .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
100 if let Some(target) = slot.as_object_mut() {
101 for (k, v) in &fields {
102 target.insert(k.clone(), v.clone());
103 }
104 }
105 root.insert(key.clone(), slot);
106 Ok((true, ()))
107 })
108}
109
110pub fn remove_credential(cred: &CredentialRef) -> std::result::Result<bool, String> {
120 let read_path = auth_file_path();
121 let write_path = crate::config::resolve_write_path("auth.json");
122 if read_path != write_path {
123 return Err(format!(
124 "{} is stored in {} which the active profile inherits; re-run without --profile \
125 (or with the profile that owns that file) to remove it",
126 cred,
127 read_path.display()
128 ));
129 }
130 remove_key_at(&write_path, &cred.storage_key())
131}
132
133pub(crate) fn remove_key_at(path: &Path, key: &str) -> std::result::Result<bool, String> {
134 let removed = if path.exists() {
140 with_locked_root(path, false, |root| {
141 let removed = root.remove(key).is_some();
142 Ok((removed, removed))
143 })?
144 } else {
145 false
146 };
147 let _ = std::fs::remove_file(refresh_pending_path(path, key));
148 Ok(removed)
149}
150
151pub(crate) fn refresh_pending_path(auth_path: &Path, key: &str) -> PathBuf {
154 let file_name = auth_path
155 .file_name()
156 .and_then(|n| n.to_str())
157 .unwrap_or("auth.json");
158 auth_path.with_file_name(format!("{file_name}.{key}.refresh.pending"))
159}
160
161#[derive(Debug, Clone, Default, PartialEq, Eq)]
165pub struct AccountMetadata {
166 pub identity: Option<String>,
168 pub added_at: Option<u64>,
170}
171
172pub fn save_account_metadata(
174 cred: &CredentialRef,
175 metadata: &AccountMetadata,
176) -> std::result::Result<(), String> {
177 let path = crate::config::resolve_write_path("auth.json");
178 save_account_metadata_at(&path, cred, metadata)
179}
180
181pub(crate) fn save_account_metadata_at(
182 path: &Path,
183 cred: &CredentialRef,
184 metadata: &AccountMetadata,
185) -> std::result::Result<(), String> {
186 let key = cred.storage_key();
187 with_locked_root(path, false, |root| {
188 let Some(entry) = root.get_mut(&key).and_then(serde_json::Value::as_object_mut) else {
189 return Err(format!("no credential stored for {key}"));
190 };
191 match &cred.account {
192 Account::Default => {
193 entry.remove("label");
194 }
195 Account::Named(label) => {
196 entry.insert("label".into(), serde_json::Value::String(label.as_str().into()));
197 }
198 }
199 match &metadata.identity {
200 Some(identity) if !identity.trim().is_empty() => {
201 entry.insert(
202 "identity".into(),
203 serde_json::Value::String(identity.trim().to_string()),
204 );
205 }
206 _ => {}
207 }
208 if let Some(added_at) = metadata.added_at {
209 entry.insert("addedAt".into(), serde_json::Value::from(added_at));
210 }
211 Ok((true, ()))
212 })
213}
214
215pub fn set_slot_identity(
230 cred: &CredentialRef,
231 account_id: &str,
232 identity: Option<&str>,
233) -> std::result::Result<(), String> {
234 let read_path = auth_file_path();
235 let write_path = crate::config::resolve_write_path("auth.json");
236 if read_path != write_path {
237 return Err(format!(
238 "{} is stored in {} which the active profile inherits; re-run without --profile \
239 (or with the profile that owns that file) to record its identity",
240 cred,
241 read_path.display()
242 ));
243 }
244 set_slot_identity_at(&write_path, cred, account_id, identity)
245}
246
247pub(crate) fn set_slot_identity_at(
248 path: &Path,
249 cred: &CredentialRef,
250 account_id: &str,
251 identity: Option<&str>,
252) -> std::result::Result<(), String> {
253 let account_id = account_id.trim();
254 if account_id.is_empty() {
255 return Err("account id is empty; nothing recorded".into());
256 }
257 let key = cred.storage_key();
258 if !path.exists() {
259 return Err(format!("no credential stored for {key}"));
260 }
261 let identity = identity.map(str::trim).filter(|s| !s.is_empty());
262 with_locked_root(path, false, |root| {
263 let Some(entry) = root
264 .get_mut(&key)
265 .and_then(serde_json::Value::as_object_mut)
266 else {
267 return Err(format!("no credential stored for {key}"));
268 };
269 if entry.get("type").and_then(|t| t.as_str()) != Some("oauth") {
270 return Err(format!(
271 "{key} is not an OAuth credential; nothing recorded"
272 ));
273 }
274 entry.insert(
275 "accountId".into(),
276 serde_json::Value::String(account_id.to_string()),
277 );
278 if let Some(identity) = identity {
279 entry.insert(
280 "identity".into(),
281 serde_json::Value::String(identity.to_string()),
282 );
283 }
284 Ok((true, ()))
285 })
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub enum CasOutcome {
291 Saved,
293 Removed,
295 Replaced,
298 StoreMissing,
301}
302
303#[cfg(test)]
309pub(crate) fn save_provider_auth_if_refresh_matches_at(
310 path: &Path,
311 key: &str,
312 expected_refresh: &str,
313 creds: &OAuthCredentials,
314) -> std::result::Result<CasOutcome, String> {
315 save_provider_auth_if_refresh_matches_typed_at(path, key, expected_refresh, creds, STORE_LOCK_WAIT)
316 .map_err(String::from)
317}
318
319pub(crate) fn save_provider_auth_if_refresh_matches_typed_at(
322 path: &Path,
323 key: &str,
324 expected_refresh: &str,
325 creds: &OAuthCredentials,
326 wait: std::time::Duration,
327) -> std::result::Result<CasOutcome, StoreError> {
328 let encoded =
329 serde_json::to_value(creds).map_err(|e| format!("Failed to serialize auth: {}", e))?;
330 let fields = encoded
331 .as_object()
332 .cloned()
333 .ok_or_else(|| "credential did not serialize to an object".to_string())?;
334 if !path.exists() {
335 return Ok(CasOutcome::StoreMissing);
336 }
337 with_locked_root_typed(path, false, wait, |root| {
338 let Some(entry) = root.get_mut(key).and_then(serde_json::Value::as_object_mut) else {
339 return Ok((false, CasOutcome::Removed));
340 };
341 let stored_refresh = entry.get("refresh").and_then(|v| v.as_str()).unwrap_or("");
342 if stored_refresh != expected_refresh {
343 return Ok((false, CasOutcome::Replaced));
344 }
345 for (k, v) in &fields {
346 entry.insert(k.clone(), v.clone());
347 }
348 Ok((true, CasOutcome::Saved))
349 })
350}
351
352#[derive(Debug, Clone, Default, PartialEq, Eq)]
355pub struct AccountInventory {
356 pub accounts: Vec<AccountSummary>,
357 pub malformed_keys: Vec<String>,
358 pub duplicate_identity_keys: Vec<String>,
361}
362
363pub fn list_accounts(provider: OAuthProviderId) -> std::result::Result<Vec<AccountSummary>, String> {
366 Ok(list_accounts_detailed_at(&auth_file_path(), Some(provider))?.accounts)
367}
368
369pub fn list_all_accounts() -> std::result::Result<Vec<AccountSummary>, String> {
371 Ok(list_accounts_detailed_at(&auth_file_path(), None)?.accounts)
372}
373
374pub fn list_accounts_detailed(
376 provider: Option<OAuthProviderId>,
377) -> std::result::Result<AccountInventory, String> {
378 list_accounts_detailed_at(&auth_file_path(), provider)
379}
380
381pub(crate) fn list_accounts_detailed_at(
382 path: &Path,
383 provider: Option<OAuthProviderId>,
384) -> std::result::Result<AccountInventory, String> {
385 let mut inventory = AccountInventory::default();
386 if !path.exists() {
387 return Ok(inventory);
388 }
389 let content = std::fs::read_to_string(path)
390 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
391 let root: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&content)
392 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
393 for (key, entry) in &root {
394 let belongs = match provider {
395 Some(p) => CredentialRef::key_belongs_to(key, p),
396 None => super::provider::DESCRIPTORS
397 .iter()
398 .any(|d| CredentialRef::key_belongs_to(key, d.id)),
399 };
400 if !belongs {
401 continue;
402 }
403 let Some(cred) = CredentialRef::parse_storage_key(key) else {
404 tracing::warn!(key_len = key.len(), "ignoring malformed account key in auth.json");
405 inventory.malformed_keys.push(display_key(key));
406 continue;
407 };
408 let Some(obj) = entry.as_object() else {
409 inventory.malformed_keys.push(display_key(key));
410 continue;
411 };
412 if obj.get("type").and_then(|t| t.as_str()) != Some("oauth") {
413 continue;
416 }
417 let token_present = |field: &str| {
421 obj.get(field)
422 .and_then(|v| v.as_str())
423 .is_some_and(|v| !v.is_empty())
424 };
425 let tokens_typed = ["access", "refresh"]
426 .iter()
427 .all(|f| matches!(obj.get(*f), None | Some(serde_json::Value::String(_))));
428 if !tokens_typed || !(token_present("access") || token_present("refresh")) {
429 tracing::warn!(key_len = key.len(), "oauth entry without usable token fields");
430 inventory.malformed_keys.push(display_key(key));
431 continue;
432 }
433 inventory.accounts.push(AccountSummary {
434 identity: obj
435 .get("identity")
436 .and_then(|v| v.as_str())
437 .filter(|s| !s.trim().is_empty())
438 .map(|s| s.trim().to_string()),
439 expires: obj.get("expires").and_then(|v| v.as_u64()).unwrap_or(0),
440 added_at: obj.get("addedAt").and_then(|v| v.as_u64()),
441 ..AccountSummary::new(cred.provider, &cred.account)
442 .with_account_id(obj.get("accountId").and_then(|v| v.as_str()))
443 });
444 }
445 inventory
446 .accounts
447 .sort_by(|a, b| (&a.provider, a.label != "default", &a.label).cmp(&(&b.provider, b.label != "default", &b.label)));
448 let mut seen: std::collections::BTreeMap<(String, String), Vec<String>> =
450 std::collections::BTreeMap::new();
451 for (key, entry) in &root {
452 let Some(cred) = CredentialRef::parse_storage_key(key) else {
453 continue;
454 };
455 if let Some(id) = entry
456 .get("accountId")
457 .and_then(|v| v.as_str())
458 .map(str::trim)
459 .filter(|s| !s.is_empty())
460 {
461 if provider.is_none_or(|p| p == cred.provider) {
462 seen.entry((cred.provider.as_str().to_string(), id.to_string()))
463 .or_default()
464 .push(key.clone());
465 }
466 }
467 }
468 for keys in seen.into_values() {
469 if keys.len() > 1 {
470 inventory.duplicate_identity_keys.extend(keys);
471 }
472 }
473 inventory.duplicate_identity_keys.sort();
474 Ok(inventory)
475}
476
477fn display_key(key: &str) -> String {
480 crate::truncate_str(key, 64).to_string()
481}
482
483pub fn any_oauth_credential_present() -> std::result::Result<bool, String> {
487 any_oauth_credential_present_at(&auth_file_path())
488}
489
490pub(crate) fn any_oauth_credential_present_at(path: &Path) -> std::result::Result<bool, String> {
491 Ok(!list_accounts_detailed_at(path, None)?.accounts.is_empty())
492}
493
494#[derive(Debug, Clone, PartialEq, Eq)]
496pub enum IdentityEvidence {
497 NoEvidence,
500 NoDuplicate,
502 Duplicate(Account),
504}
505
506#[derive(Debug, Clone, PartialEq, Eq)]
508pub struct LoginPersistOutcome {
509 pub evidence: IdentityEvidence,
510 pub saved: bool,
512}
513
514pub fn save_credential_unless_duplicate(
529 cred: &CredentialRef,
530 creds: &OAuthCredentials,
531 metadata: &AccountMetadata,
532) -> std::result::Result<LoginPersistOutcome, String> {
533 let read_path = auth_file_path();
534 let write_path = crate::config::resolve_write_path("auth.json");
535 if read_path != write_path && read_path.exists() {
536 return Err(format!(
537 "the active profile has no auth.json of its own and inherits {}; logging in here would \
538 create a profile store that hides every inherited account (tokens are never copied). \
539 Re-run without --profile to add the account to the shared store.",
540 read_path.display()
541 ));
542 }
543 save_credential_unless_duplicate_at(&write_path, cred, creds, metadata)
544}
545
546pub(crate) fn save_credential_unless_duplicate_at(
547 path: &Path,
548 cred: &CredentialRef,
549 creds: &OAuthCredentials,
550 metadata: &AccountMetadata,
551) -> std::result::Result<LoginPersistOutcome, String> {
552 let encoded =
553 serde_json::to_value(creds).map_err(|e| format!("Failed to serialize auth: {}", e))?;
554 let fields = encoded
555 .as_object()
556 .cloned()
557 .ok_or_else(|| "credential did not serialize to an object".to_string())?;
558 let key = cred.storage_key();
559 let account_id = creds
560 .account_id
561 .as_deref()
562 .map(str::trim)
563 .filter(|s| !s.is_empty());
564 with_locked_root(path, false, |root| {
565 let mut duplicate: Option<Account> = None;
567 for (other_key, entry) in root.iter() {
568 if other_key == &key {
569 continue;
570 }
571 let Some(other) = CredentialRef::parse_storage_key(other_key) else {
572 continue;
573 };
574 if other.provider != cred.provider {
575 continue;
576 }
577 let stored_id = entry
578 .get("accountId")
579 .and_then(|v| v.as_str())
580 .map(str::trim)
581 .unwrap_or("");
582 let stored_refresh = entry.get("refresh").and_then(|v| v.as_str()).unwrap_or("");
583 let same_seat = account_id.is_some_and(|id| id == stored_id)
584 || (!creds.refresh.is_empty() && creds.refresh == stored_refresh);
585 if same_seat {
586 duplicate = Some(other.account);
587 break;
588 }
589 }
590 if let Some(account) = duplicate {
591 return Ok((
592 false,
593 LoginPersistOutcome {
594 evidence: IdentityEvidence::Duplicate(account),
595 saved: false,
596 },
597 ));
598 }
599 let mut slot = root
600 .remove(&key)
601 .filter(serde_json::Value::is_object)
602 .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
603 if let Some(target) = slot.as_object_mut() {
604 for (k, v) in &fields {
605 target.insert(k.clone(), v.clone());
606 }
607 if account_id.is_none() {
608 target.remove("accountId");
611 }
612 match &cred.account {
613 Account::Default => {
614 target.remove("label");
615 }
616 Account::Named(label) => {
617 target.insert("label".into(), serde_json::Value::String(label.as_str().into()));
618 }
619 }
620 match metadata.identity.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
621 Some(identity) => {
622 target.insert("identity".into(), serde_json::Value::String(identity.into()));
623 }
624 None => {
625 target.remove("identity");
626 }
627 }
628 match metadata.added_at {
629 Some(added_at) => {
630 target.insert("addedAt".into(), serde_json::Value::from(added_at));
631 }
632 None => {
633 target.remove("addedAt");
634 }
635 }
636 }
637 root.insert(key.clone(), slot);
638 Ok((
639 true,
640 LoginPersistOutcome {
641 evidence: if account_id.is_some() {
642 IdentityEvidence::NoDuplicate
643 } else {
644 IdentityEvidence::NoEvidence
645 },
646 saved: true,
647 },
648 ))
649 })
650}
651
652pub fn find_duplicate_identity(
655 provider: OAuthProviderId,
656 account_id: &str,
657 except: &Account,
658) -> std::result::Result<Option<Account>, String> {
659 find_duplicate_identity_at(&auth_file_path(), provider, account_id, except)
660}
661
662pub(crate) fn find_duplicate_identity_at(
663 path: &Path,
664 provider: OAuthProviderId,
665 account_id: &str,
666 except: &Account,
667) -> std::result::Result<Option<Account>, String> {
668 let account_id = account_id.trim();
669 if account_id.is_empty() || !path.exists() {
670 return Ok(None);
671 }
672 let content = std::fs::read_to_string(path)
673 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
674 let root: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&content)
675 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
676 for (key, entry) in &root {
677 let Some(cred) = CredentialRef::parse_storage_key(key) else {
678 continue;
679 };
680 if cred.provider != provider || &cred.account == except {
681 continue;
682 }
683 let stored = entry
684 .get("accountId")
685 .and_then(|v| v.as_str())
686 .map(str::trim)
687 .unwrap_or("");
688 if !stored.is_empty() && stored == account_id {
689 return Ok(Some(cred.account));
690 }
691 }
692 Ok(None)
693}
694
695pub fn save_auth(creds: &OAuthCredentials) -> std::result::Result<(), String> {
697 save_provider_auth("anthropic", creds)
698}
699
700pub fn save_provider_auth(
702 provider: &str,
703 creds: &OAuthCredentials,
704) -> std::result::Result<(), String> {
705 let path = crate::config::resolve_write_path("auth.json");
706 save_provider_auth_at(&path, provider, creds)
707}
708
709pub fn load_cloud_state(provider: &str) -> std::result::Result<Option<serde_json::Value>, String> {
711 let path = auth_file_path();
712 if !path.exists() {
713 return Ok(None);
714 }
715 let root: serde_json::Value = serde_json::from_str(
716 &std::fs::read_to_string(&path)
717 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?,
718 )
719 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
720 Ok(root
721 .get(provider)
722 .and_then(|v| v.get("cloud_state"))
723 .cloned())
724}
725
726pub fn save_cloud_state(
728 provider: &str,
729 state: &serde_json::Value,
730) -> std::result::Result<(), String> {
731 let path = crate::config::resolve_write_path("auth.json");
732 let mut fields = serde_json::Map::new();
733 fields.insert("type".into(), serde_json::Value::String("cloud".into()));
734 fields.insert("cloud_state".into(), state.clone());
735 save_provider_fields_at(&path, provider, &fields)
736}
737
738pub fn load_static_key(provider: &str) -> std::result::Result<Option<String>, String> {
744 load_static_key_at(&auth_file_path(), provider)
745}
746
747pub(crate) fn load_static_key_at(
748 path: &std::path::Path,
749 provider: &str,
750) -> std::result::Result<Option<String>, String> {
751 if !path.exists() {
752 return Ok(None);
753 }
754 let content = std::fs::read_to_string(path)
755 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
756 let value: serde_json::Value = serde_json::from_str(&content)
757 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
758 let Some(entry) = value.get(provider) else {
759 return Ok(None);
760 };
761 if entry.get("type").and_then(|t| t.as_str()) != Some("api_key") {
762 return Ok(None);
763 }
764 Ok(entry
765 .get("key")
766 .and_then(|k| k.as_str())
767 .filter(|k| !k.is_empty())
768 .map(str::to_string))
769}
770
771pub fn save_static_key(provider: &str, key: &str) -> std::result::Result<(), String> {
774 let path = crate::config::resolve_write_path("auth.json");
775 save_static_key_at(&path, provider, key)
776}
777
778pub(crate) fn save_static_key_at(
779 path: &std::path::Path,
780 provider: &str,
781 key: &str,
782) -> std::result::Result<(), String> {
783 let mut fields = serde_json::Map::new();
784 fields.insert(
785 "type".to_string(),
786 serde_json::Value::String("api_key".to_string()),
787 );
788 fields.insert(
789 "key".to_string(),
790 serde_json::Value::String(key.to_string()),
791 );
792 save_provider_fields_at(path, provider, &fields)
793}
794
795pub(crate) fn save_provider_auth_typed(
801 provider: &str,
802 creds: &OAuthCredentials,
803) -> std::result::Result<(), StoreError> {
804 let path = crate::config::resolve_write_path("auth.json");
805 save_provider_auth_typed_at(&path, provider, creds)
806}
807
808pub(crate) fn save_provider_auth_typed_at(
809 path: &std::path::Path,
810 provider: &str,
811 creds: &OAuthCredentials,
812) -> std::result::Result<(), StoreError> {
813 let encoded =
814 serde_json::to_value(creds).map_err(|e| format!("Failed to serialize auth: {}", e))?;
815 let fields = encoded
816 .as_object()
817 .ok_or_else(|| "credential did not serialize to an object".to_string())?;
818 with_locked_root_typed(path, true, STORE_LOCK_WAIT, |root| {
819 merge_provider_fields(root, provider, fields);
820 Ok((true, ()))
821 })
822}
823
824pub(crate) fn save_provider_auth_at(
825 path: &std::path::Path,
826 provider: &str,
827 creds: &OAuthCredentials,
828) -> std::result::Result<(), String> {
829 let encoded =
830 serde_json::to_value(creds).map_err(|e| format!("Failed to serialize auth: {}", e))?;
831 let fields = encoded
832 .as_object()
833 .ok_or_else(|| "credential did not serialize to an object".to_string())?;
834 save_provider_fields_at(path, provider, fields)
835}
836
837fn save_provider_fields_at(
840 path: &std::path::Path,
841 provider: &str,
842 fields: &serde_json::Map<String, serde_json::Value>,
843) -> std::result::Result<(), String> {
844 with_locked_root(path, true, |root| {
845 merge_provider_fields(root, provider, fields);
846 Ok((true, ()))
847 })
848}
849
850fn merge_provider_fields(
854 root: &mut serde_json::Map<String, serde_json::Value>,
855 provider: &str,
856 fields: &serde_json::Map<String, serde_json::Value>,
857) {
858 let mut provider_value = root
859 .remove(provider)
860 .filter(serde_json::Value::is_object)
861 .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
862 if let Some(target) = provider_value.as_object_mut() {
863 for (key, value) in fields {
864 target.insert(key.clone(), value.clone());
865 }
866 }
867 root.insert(provider.to_string(), provider_value);
868}
869
870fn with_locked_root<T>(
878 path: &std::path::Path,
879 recover_corrupt: bool,
880 edit: impl FnOnce(
881 &mut serde_json::Map<String, serde_json::Value>,
882 ) -> std::result::Result<(bool, T), String>,
883) -> std::result::Result<T, String> {
884 with_locked_root_typed(path, recover_corrupt, STORE_LOCK_WAIT, edit).map_err(String::from)
885}
886
887pub(crate) const STORE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(15);
891
892#[derive(Debug)]
895pub(crate) enum StoreError {
896 Busy(String),
897 Corrupt(String),
900 Failed(String),
901}
902
903impl From<StoreError> for String {
904 fn from(e: StoreError) -> Self {
905 match e {
906 StoreError::Busy(m) | StoreError::Corrupt(m) | StoreError::Failed(m) => m,
907 }
908 }
909}
910
911pub(crate) fn corrupt_store(path: &Path, err: &serde_json::Error) -> StoreError {
915 let ts = std::time::SystemTime::now()
916 .duration_since(std::time::UNIX_EPOCH)
917 .map(|d| d.as_secs())
918 .unwrap_or(0);
919 let p = path.display().to_string();
920 StoreError::Corrupt(format!(
921 "{p} is not valid JSON ({err}); synaps will not overwrite it because it may hold other \
922 accounts. Move it aside with `mv {} {}`, then log in again (or repair it by hand).",
923 shell_quote(&p),
924 shell_quote(&format!("{p}.broken-{ts}"))
925 ))
926}
927
928fn shell_quote(s: &str) -> String {
930 format!("'{}'", s.replace('\'', r"'\''"))
931}
932
933impl From<String> for StoreError {
934 fn from(m: String) -> Self {
935 StoreError::Failed(m)
936 }
937}
938
939fn lock_store_exclusive(
941 lock_file: &std::fs::File,
942 lock_path: &std::path::Path,
943 wait: std::time::Duration,
944) -> std::result::Result<(), StoreError> {
945 use fs4::fs_std::FileExt;
946 let deadline = std::time::Instant::now() + wait;
947 loop {
948 match FileExt::try_lock_exclusive(lock_file) {
949 Ok(true) => return Ok(()),
950 Ok(false) => {}
951 Err(e) => {
952 return Err(StoreError::Failed(format!(
953 "Failed to lock {}: {}",
954 lock_path.display(),
955 e
956 )))
957 }
958 }
959 if std::time::Instant::now() >= deadline {
960 return Err(StoreError::Busy(format!(
961 "timed out after {}s waiting for {} (another synaps process holds it)",
962 wait.as_secs(),
963 lock_path.display()
964 )));
965 }
966 std::thread::sleep(std::time::Duration::from_millis(20));
967 }
968}
969
970fn with_locked_root_typed<T>(
971 path: &std::path::Path,
972 recover_corrupt: bool,
973 wait: std::time::Duration,
974 edit: impl FnOnce(
975 &mut serde_json::Map<String, serde_json::Value>,
976 ) -> std::result::Result<(bool, T), String>,
977) -> std::result::Result<T, StoreError> {
978 if let Some(parent) = path.parent() {
980 std::fs::create_dir_all(parent)
981 .map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
982 }
983
984 let lock_path = path.with_extension("json.lock");
989 let lock_file = std::fs::OpenOptions::new()
990 .create(true)
991 .truncate(true)
992 .write(true)
993 .open(&lock_path)
994 .map_err(|e| format!("Failed to open lock file {}: {}", lock_path.display(), e))?;
995 lock_store_exclusive(&lock_file, &lock_path, wait)?;
996
997 let mut root = if path.exists() {
998 let content = std::fs::read_to_string(path)
999 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
1000 match serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&content) {
1007 Ok(map) => map,
1008 Err(e) if !recover_corrupt || !content.trim().is_empty() => {
1009 return Err(corrupt_store(path, &e));
1010 }
1011 Err(e) => {
1012 tracing::warn!(
1013 path = %path.display(),
1014 error = %e,
1015 "auth.json could not be parsed as a JSON object; replacing with a fresh structure"
1016 );
1017 let ts = std::time::SystemTime::now()
1019 .duration_since(std::time::UNIX_EPOCH)
1020 .map(|d| d.as_secs())
1021 .unwrap_or(0);
1022 let backup = path.with_extension(format!("json.corrupt.{}", ts));
1023 match std::fs::copy(path, &backup) {
1024 Ok(_) => {
1025 eprintln!(
1026 "[warn] auth.json was corrupt and has been reset. Backup saved to: {}",
1027 backup.display()
1028 );
1029 }
1030 Err(copy_err) => {
1031 eprintln!(
1032 "[warn] auth.json was corrupt and has been reset, but backup failed: {}",
1033 copy_err
1034 );
1035 }
1036 }
1037 serde_json::Map::new()
1038 }
1039 }
1040 } else {
1041 serde_json::Map::new()
1042 };
1043
1044 let (write, result) = edit(&mut root)?;
1045 if !write {
1046 return Ok(result);
1047 }
1048
1049 let json = serde_json::to_string_pretty(&root)
1050 .map_err(|e| format!("Failed to serialize auth: {}", e))?;
1051
1052 let tmp_path = path.with_extension("json.tmp");
1057 {
1058 use std::io::Write;
1059 let mut file = std::fs::OpenOptions::new()
1060 .write(true)
1061 .create(true)
1062 .truncate(true)
1063 .open(&tmp_path)
1064 .map_err(|e| format!("Failed to create {}: {}", tmp_path.display(), e))?;
1065
1066 #[cfg(unix)]
1067 {
1068 use std::os::unix::fs::PermissionsExt;
1069 let perms = std::fs::Permissions::from_mode(0o600);
1070 file.set_permissions(perms).map_err(|e| {
1071 format!("Failed to set permissions on {}: {}", tmp_path.display(), e)
1072 })?;
1073 }
1074
1075 file.write_all(json.as_bytes())
1076 .map_err(|e| format!("Failed to write {}: {}", tmp_path.display(), e))?;
1077 file.sync_all()
1078 .map_err(|e| format!("Failed to fsync {}: {}", tmp_path.display(), e))?;
1079 }
1080
1081 std::fs::rename(&tmp_path, path)
1082 .map_err(|e| format!("Failed to atomically replace {}: {}", path.display(), e))?;
1083
1084 Ok(result)
1085}
1086
1087#[cfg(test)]
1091pub(super) fn save_provider_auth_at_test_hook(
1092 path: &std::path::Path,
1093 provider: &str,
1094 creds: &OAuthCredentials,
1095) -> std::result::Result<(), String> {
1096 save_provider_auth_at(path, provider, creds)
1097}
1098
1099#[cfg(test)]
1100mod tests {
1101 use super::*;
1102
1103 fn fresh_creds() -> OAuthCredentials {
1104 OAuthCredentials {
1105 auth_type: "oauth".to_string(),
1106 refresh: "r".to_string(),
1107 access: "a".to_string(),
1108 expires: 1,
1109 account_id: None,
1110 }
1111 }
1112
1113 #[test]
1114 fn save_preserves_unknown_nested_provider_metadata() {
1115 let dir = tempfile::tempdir().expect("tempdir");
1116 let path = dir.path().join("auth.json");
1117 std::fs::write(&path, r#"{"openai-codex":{"type":"oauth","refresh":"old","access":"old","expires":1,"metadata":{"tenant":"t1","flags":{"beta":true}}}}"#).unwrap();
1118 save_provider_auth_at(&path, "openai-codex", &fresh_creds()).unwrap();
1119 let value: serde_json::Value =
1120 serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
1121 assert_eq!(value["openai-codex"]["metadata"]["tenant"], "t1");
1122 assert_eq!(value["openai-codex"]["metadata"]["flags"]["beta"], true);
1123 assert_eq!(value["openai-codex"]["access"], "a");
1124 }
1125
1126 #[test]
1127 fn save_provider_auth_at_creates_file_when_absent() {
1128 let dir = tempfile::tempdir().expect("tempdir");
1129 let path = dir.path().join("auth.json");
1130 save_provider_auth_at(&path, "openai-codex", &fresh_creds()).expect("save");
1131 assert!(path.exists());
1132 let content = std::fs::read_to_string(&path).unwrap();
1133 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1134 assert!(parsed.get("openai-codex").is_some());
1135 }
1136
1137 #[test]
1138 fn save_provider_auth_at_preserves_other_providers() {
1139 let dir = tempfile::tempdir().expect("tempdir");
1140 let path = dir.path().join("auth.json");
1141 std::fs::write(
1142 &path,
1143 r#"{"anthropic":{"type":"oauth","refresh":"r2","access":"a2","expires":2}}"#,
1144 )
1145 .unwrap();
1146 save_provider_auth_at(&path, "openai-codex", &fresh_creds()).expect("save");
1147 let content = std::fs::read_to_string(&path).unwrap();
1148 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1149 assert!(
1150 parsed.get("anthropic").is_some(),
1151 "must keep anthropic entry"
1152 );
1153 assert!(parsed.get("openai-codex").is_some());
1154 }
1155
1156 #[test]
1160 fn legacy_save_refuses_to_reset_a_corrupt_store() {
1161 let dir = tempfile::tempdir().expect("tempdir");
1162 let path = dir.path().join("auth.json");
1163 let corrupt = r#"{"anthropic":{"type":"oauth","refresh":"r1"},"anthropic@work":{"type":"oau"#;
1165 std::fs::write(&path, corrupt).unwrap();
1166 for save in [
1167 save_provider_auth_at(&path, "openai-codex", &fresh_creds()),
1168 save_static_key_at(&path, "groq", "gsk-1"),
1169 ] {
1170 let err = save.expect_err("must not reset a corrupt store");
1171 assert!(err.contains("not valid JSON"), "{err}");
1172 assert!(err.contains("other accounts"), "{err}");
1173 }
1174 assert_eq!(std::fs::read_to_string(&path).unwrap(), corrupt, "file untouched");
1175 let backups = std::fs::read_dir(dir.path())
1176 .unwrap()
1177 .filter(|e| e.as_ref().unwrap().file_name().to_string_lossy().contains(".corrupt."))
1178 .count();
1179 assert_eq!(backups, 0, "nothing reset, nothing to back up");
1180 }
1181
1182 #[test]
1183 fn legacy_save_refuses_a_non_object_root() {
1184 let dir = tempfile::tempdir().expect("tempdir");
1185 let path = dir.path().join("auth.json");
1186 std::fs::write(&path, "[1, 2, 3]").unwrap();
1187 save_provider_auth_at(&path, "openai-codex", &fresh_creds())
1188 .expect_err("a non-object root may still hold data");
1189 assert_eq!(std::fs::read_to_string(&path).unwrap(), "[1, 2, 3]");
1190 }
1191
1192 #[test]
1195 fn legacy_save_resets_an_empty_store_with_a_backup() {
1196 for content in ["", " \n\t "] {
1197 let dir = tempfile::tempdir().expect("tempdir");
1198 let path = dir.path().join("auth.json");
1199 std::fs::write(&path, content).unwrap();
1200 save_provider_auth_at(&path, "openai-codex", &fresh_creds())
1201 .expect("empty store is reset");
1202 let parsed: serde_json::Value =
1203 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1204 assert!(parsed.get("openai-codex").is_some());
1205 let backups = std::fs::read_dir(dir.path())
1206 .unwrap()
1207 .filter(|e| e.as_ref().unwrap().file_name().to_string_lossy().contains(".corrupt."))
1208 .count();
1209 assert_eq!(backups, 1, "{content:?}");
1210 }
1211 }
1212
1213 #[test]
1218 fn static_key_save_preserves_oauth_entries_and_roundtrips() {
1219 let dir = tempfile::tempdir().expect("tempdir");
1220 let path = dir.path().join("auth.json");
1221 save_provider_auth_at(&path, "anthropic", &fresh_creds()).expect("save oauth");
1222 save_static_key_at(&path, "groq", "gsk-secret-1").expect("save static key");
1223
1224 assert_eq!(
1225 load_static_key_at(&path, "groq").unwrap().as_deref(),
1226 Some("gsk-secret-1")
1227 );
1228 let parsed: serde_json::Value =
1229 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1230 assert_eq!(
1231 parsed["anthropic"]["refresh"], "r",
1232 "OAuth entry must survive"
1233 );
1234 assert_eq!(parsed["groq"]["type"], "api_key");
1235 }
1236
1237 #[test]
1239 fn oauth_save_preserves_static_key_entries() {
1240 let dir = tempfile::tempdir().expect("tempdir");
1241 let path = dir.path().join("auth.json");
1242 save_static_key_at(&path, "openrouter", "sk-or-1").expect("save static key");
1243 save_provider_auth_at(&path, "openai-codex", &fresh_creds()).expect("save oauth");
1244 assert_eq!(
1245 load_static_key_at(&path, "openrouter").unwrap().as_deref(),
1246 Some("sk-or-1")
1247 );
1248 }
1249
1250 #[test]
1252 fn static_key_upsert_preserves_entry_metadata() {
1253 let dir = tempfile::tempdir().expect("tempdir");
1254 let path = dir.path().join("auth.json");
1255 std::fs::write(
1256 &path,
1257 r#"{"groq":{"type":"api_key","key":"old","note":{"source":"migrated"}}}"#,
1258 )
1259 .unwrap();
1260 save_static_key_at(&path, "groq", "new-key").expect("upsert");
1261 let parsed: serde_json::Value =
1262 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1263 assert_eq!(parsed["groq"]["key"], "new-key");
1264 assert_eq!(parsed["groq"]["note"]["source"], "migrated");
1265 }
1266
1267 #[test]
1270 fn load_static_key_ignores_oauth_entries() {
1271 let dir = tempfile::tempdir().expect("tempdir");
1272 let path = dir.path().join("auth.json");
1273 save_provider_auth_at(&path, "anthropic", &fresh_creds()).expect("save oauth");
1274 assert_eq!(load_static_key_at(&path, "anthropic").unwrap(), None);
1275 assert_eq!(load_static_key_at(&path, "missing").unwrap(), None);
1276 }
1277
1278 #[test]
1295 fn bug159_anthropic_login_preserves_existing_openai_codex() {
1296 let dir = tempfile::tempdir().expect("tempdir");
1297 let path = dir.path().join("auth.json");
1298
1299 let codex_creds = OAuthCredentials {
1301 auth_type: "oauth".to_string(),
1302 refresh: "codex-refresh".to_string(),
1303 access: "codex-access".to_string(),
1304 expires: 9_999_999_999_000,
1305 account_id: Some("acct_codex_123".to_string()),
1306 };
1307 save_provider_auth_at(&path, "openai-codex", &codex_creds).expect("save openai-codex");
1308
1309 let anthropic_creds = OAuthCredentials {
1311 auth_type: "oauth".to_string(),
1312 refresh: "anth-refresh".to_string(),
1313 access: "anth-access".to_string(),
1314 expires: 9_999_999_999_000,
1315 account_id: None,
1316 };
1317 save_provider_auth_at(&path, "anthropic", &anthropic_creds).expect("save anthropic");
1318
1319 let content = std::fs::read_to_string(&path).unwrap();
1321 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1322
1323 assert!(
1324 parsed.get("anthropic").is_some(),
1325 "anthropic credential must be present after login"
1326 );
1327 assert!(
1328 parsed.get("openai-codex").is_some(),
1329 "bug #159: openai-codex credential must NOT be wiped when logging in as anthropic"
1330 );
1331 assert_eq!(
1332 parsed["openai-codex"]["refresh"].as_str(),
1333 Some("codex-refresh"),
1334 "openai-codex refresh token must be unchanged"
1335 );
1336 assert_eq!(
1337 parsed["anthropic"]["refresh"].as_str(),
1338 Some("anth-refresh"),
1339 "anthropic refresh token must be correctly saved"
1340 );
1341 }
1342
1343 #[test]
1346 fn bug159_openai_codex_login_preserves_existing_anthropic() {
1347 let dir = tempfile::tempdir().expect("tempdir");
1348 let path = dir.path().join("auth.json");
1349
1350 let anthropic_creds = OAuthCredentials {
1352 auth_type: "oauth".to_string(),
1353 refresh: "anth-refresh-first".to_string(),
1354 access: "anth-access-first".to_string(),
1355 expires: 9_999_999_999_000,
1356 account_id: None,
1357 };
1358 save_provider_auth_at(&path, "anthropic", &anthropic_creds).expect("save anthropic");
1359
1360 let codex_creds = OAuthCredentials {
1362 auth_type: "oauth".to_string(),
1363 refresh: "codex-refresh-second".to_string(),
1364 access: "codex-access-second".to_string(),
1365 expires: 9_999_999_999_000,
1366 account_id: Some("acct_456".to_string()),
1367 };
1368 save_provider_auth_at(&path, "openai-codex", &codex_creds).expect("save openai-codex");
1369
1370 let content = std::fs::read_to_string(&path).unwrap();
1371 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1372
1373 assert!(
1374 parsed.get("anthropic").is_some(),
1375 "anthropic credential must survive openai-codex login"
1376 );
1377 assert!(
1378 parsed.get("openai-codex").is_some(),
1379 "openai-codex credential must be present"
1380 );
1381 assert_eq!(
1382 parsed["anthropic"]["refresh"].as_str(),
1383 Some("anth-refresh-first"),
1384 "anthropic refresh token must be unchanged"
1385 );
1386 }
1387
1388 #[test]
1391 fn bug159_third_provider_login_preserves_both_existing_providers() {
1392 let dir = tempfile::tempdir().expect("tempdir");
1393 let path = dir.path().join("auth.json");
1394
1395 save_provider_auth_at(
1396 &path,
1397 "anthropic",
1398 &OAuthCredentials {
1399 auth_type: "oauth".to_string(),
1400 refresh: "anth-r".to_string(),
1401 access: "anth-a".to_string(),
1402 expires: 1,
1403 account_id: None,
1404 },
1405 )
1406 .expect("save anthropic");
1407
1408 save_provider_auth_at(
1409 &path,
1410 "openai-codex",
1411 &OAuthCredentials {
1412 auth_type: "oauth".to_string(),
1413 refresh: "codex-r".to_string(),
1414 access: "codex-a".to_string(),
1415 expires: 1,
1416 account_id: Some("acct_codex".to_string()),
1417 },
1418 )
1419 .expect("save openai-codex");
1420
1421 save_provider_auth_at(
1422 &path,
1423 "future-provider",
1424 &OAuthCredentials {
1425 auth_type: "oauth".to_string(),
1426 refresh: "future-r".to_string(),
1427 access: "future-a".to_string(),
1428 expires: 1,
1429 account_id: None,
1430 },
1431 )
1432 .expect("save future-provider");
1433
1434 let content = std::fs::read_to_string(&path).unwrap();
1435 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1436
1437 assert!(parsed.get("anthropic").is_some(), "anthropic must survive");
1438 assert!(
1439 parsed.get("openai-codex").is_some(),
1440 "openai-codex must survive"
1441 );
1442 assert!(
1443 parsed.get("future-provider").is_some(),
1444 "future-provider must be present"
1445 );
1446 }
1447
1448 #[test]
1451 fn bug159_same_provider_login_upserts_in_place() {
1452 let dir = tempfile::tempdir().expect("tempdir");
1453 let path = dir.path().join("auth.json");
1454
1455 save_provider_auth_at(
1456 &path,
1457 "anthropic",
1458 &OAuthCredentials {
1459 auth_type: "oauth".to_string(),
1460 refresh: "old-refresh".to_string(),
1461 access: "old-access".to_string(),
1462 expires: 1,
1463 account_id: None,
1464 },
1465 )
1466 .expect("initial save");
1467
1468 save_provider_auth_at(
1469 &path,
1470 "anthropic",
1471 &OAuthCredentials {
1472 auth_type: "oauth".to_string(),
1473 refresh: "new-refresh".to_string(),
1474 access: "new-access".to_string(),
1475 expires: 2,
1476 account_id: None,
1477 },
1478 )
1479 .expect("upsert save");
1480
1481 let content = std::fs::read_to_string(&path).unwrap();
1482 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1483 let obj = parsed.as_object().unwrap();
1484
1485 assert_eq!(obj.len(), 1, "only one anthropic entry — no duplicates");
1486 assert_eq!(
1487 parsed["anthropic"]["refresh"].as_str(),
1488 Some("new-refresh"),
1489 "credential must be updated to latest value"
1490 );
1491 }
1492
1493 fn creds(refresh: &str, account_id: Option<&str>) -> OAuthCredentials {
1496 OAuthCredentials {
1497 auth_type: "oauth".to_string(),
1498 refresh: refresh.to_string(),
1499 access: format!("{refresh}-access"),
1500 expires: 5,
1501 account_id: account_id.map(str::to_string),
1502 }
1503 }
1504
1505 fn named(provider: OAuthProviderId, label: &str) -> CredentialRef {
1506 CredentialRef::new(provider, Account::named(label).unwrap())
1507 }
1508
1509 fn read_root(path: &Path) -> serde_json::Map<String, serde_json::Value> {
1510 serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
1511 }
1512
1513 #[test]
1514 fn named_slot_save_never_touches_default_slot() {
1515 let dir = tempfile::tempdir().unwrap();
1516 let path = dir.path().join("auth.json");
1517 std::fs::write(
1519 &path,
1520 r#"{"openai-codex":{"type":"oauth","refresh":"legacy-r","access":"legacy-a","expires":1,"accountId":"acct-legacy"}}"#,
1521 )
1522 .unwrap();
1523 let astra2 = named(OAuthProviderId::OpenAiCodex, "astra2");
1524 save_provider_auth_at(&path, &astra2.storage_key(), &creds("r2", Some("acct-2"))).unwrap();
1525 let root = read_root(&path);
1526 assert_eq!(root["openai-codex"]["refresh"], "legacy-r", "default slot untouched");
1527 assert_eq!(root["openai-codex"]["accountId"], "acct-legacy");
1528 assert_eq!(root["openai-codex@astra2"]["refresh"], "r2");
1529 assert_eq!(root.len(), 2);
1530
1531 let legacy = load_provider_auth_at(&path, "openai-codex").unwrap().unwrap();
1533 assert_eq!(legacy.refresh, "legacy-r");
1534 let slot = load_provider_auth_at(&path, &astra2.storage_key()).unwrap().unwrap();
1535 assert_eq!(slot.refresh, "r2");
1536 assert!(load_provider_auth_at(&path, "openai-codex@missing").unwrap().is_none());
1538 }
1539
1540 #[test]
1541 fn list_accounts_reports_default_named_and_malformed_without_secrets() {
1542 let dir = tempfile::tempdir().unwrap();
1543 let path = dir.path().join("auth.json");
1544 std::fs::write(
1545 &path,
1546 r#"{
1547 "openai-codex": {"type":"oauth","refresh":"R0","access":"A0","expires":10,"accountId":"2b2f0000-1111"},
1548 "openai-codex@astra2": {"type":"oauth","refresh":"R1","access":"A1","expires":20,"accountId":"7f530000-2222","label":"astra2","identity":"a@example.com","addedAt":99},
1549 "openai-codex@Bad Label": {"type":"oauth","refresh":"R2","access":"A2","expires":0},
1550 "openai-codex@default": {"type":"oauth","refresh":"R3","access":"A3","expires":0},
1551 "openai-codex@notoken": {"type":"oauth","expires":5},
1552 "openai-codex@badtype": {"type":"oauth","refresh":{"nested":"SECRET-VALUE"},"access":"A5","expires":5},
1553 "openai-codex@empty": {"type":"oauth","refresh":"","access":"","expires":5},
1554 "anthropic": {"type":"oauth","refresh":"R4","access":"A4","expires":30},
1555 "groq": {"type":"api_key","key":"gsk-secret"},
1556 "aws-bedrock": {"type":"cloud","cloud_state":{}}
1557 }"#,
1558 )
1559 .unwrap();
1560 let inv = list_accounts_detailed_at(&path, Some(OAuthProviderId::OpenAiCodex)).unwrap();
1561 assert_eq!(inv.accounts.len(), 2, "{:?}", inv.accounts);
1562 assert_eq!(inv.accounts[0].label, "default");
1563 assert_eq!(inv.accounts[0].account_id_prefix.as_deref(), Some("2b2f0000"));
1564 assert_eq!(
1565 inv.accounts[0].seat_fingerprint,
1566 super::super::account::seat_fingerprint(OAuthProviderId::OpenAiCodex, "2b2f0000-1111")
1567 );
1568 assert_ne!(inv.accounts[0].seat_fingerprint, inv.accounts[1].seat_fingerprint);
1569 assert_eq!(inv.accounts[0].expires, 10);
1570 assert_eq!(inv.accounts[1].label, "astra2");
1571 assert_eq!(inv.accounts[1].identity.as_deref(), Some("a@example.com"));
1572 assert_eq!(inv.accounts[1].added_at, Some(99));
1573 assert_eq!(inv.malformed_keys.len(), 5, "{:?}", inv.malformed_keys);
1574 for key in [
1575 "openai-codex@Bad Label",
1576 "openai-codex@default",
1577 "openai-codex@notoken",
1578 "openai-codex@badtype",
1579 "openai-codex@empty",
1580 ] {
1581 assert!(inv.malformed_keys.contains(&key.to_string()), "{key}");
1582 }
1583 let diag = format!("{:?}", inv);
1584 assert!(!diag.contains("SECRET-VALUE"), "diagnostics must not dump values");
1585
1586 let all = list_accounts_detailed_at(&path, None).unwrap();
1587 assert_eq!(all.accounts.len(), 3);
1588 assert!(all.accounts.iter().all(|a| a.provider != "groq" && a.provider != "aws-bedrock"));
1589 let json = serde_json::to_string(&all.accounts).unwrap();
1590 for secret in ["R0", "R1", "R4", "A0", "A1", "A4", "A5", "gsk-secret", "SECRET-VALUE"] {
1591 assert!(!json.contains(secret), "listing leaked {secret}: {json}");
1592 }
1593 assert!(!json.contains("2b2f0000-1111"), "full account id must not be exposed");
1594 assert!(any_oauth_credential_present_at(&path).unwrap());
1595 }
1596
1597 #[test]
1598 fn any_oauth_credential_present_accepts_non_anthropic_only_and_rejects_empty() {
1599 let dir = tempfile::tempdir().unwrap();
1600 let path = dir.path().join("auth.json");
1601 assert!(!any_oauth_credential_present_at(&path).unwrap(), "missing file");
1602 std::fs::write(&path, r#"{"groq":{"type":"api_key","key":"k"}}"#).unwrap();
1603 assert!(!any_oauth_credential_present_at(&path).unwrap(), "static keys are not OAuth");
1604 std::fs::write(
1605 &path,
1606 r#"{"kimi-code@m27":{"type":"oauth","refresh":"r","access":"a","expires":1}}"#,
1607 )
1608 .unwrap();
1609 assert!(any_oauth_credential_present_at(&path).unwrap(), "named Kimi-only install");
1610 std::fs::write(&path, "not json").unwrap();
1611 assert!(any_oauth_credential_present_at(&path).is_err());
1612 }
1613
1614 #[test]
1615 fn remove_credential_removes_exactly_one_key() {
1616 let dir = tempfile::tempdir().unwrap();
1617 let path = dir.path().join("auth.json");
1618 save_provider_auth_at(&path, "openai-codex", &creds("r0", None)).unwrap();
1619 save_provider_auth_at(&path, "openai-codex@a", &creds("ra", None)).unwrap();
1620 save_provider_auth_at(&path, "openai-codex@b", &creds("rb", None)).unwrap();
1621 assert!(remove_key_at(&path, "openai-codex@a").unwrap());
1622 let root = read_root(&path);
1623 assert_eq!(root.len(), 2);
1624 assert_eq!(root["openai-codex"]["refresh"], "r0");
1625 assert_eq!(root["openai-codex@b"]["refresh"], "rb");
1626 assert!(!remove_key_at(&path, "openai-codex@a").unwrap(), "idempotent");
1627 assert!(!path.with_extension("json.tmp").exists());
1628 std::fs::write(&path, "garbage{").unwrap();
1630 assert!(remove_key_at(&path, "openai-codex").is_err());
1631 assert_eq!(std::fs::read_to_string(&path).unwrap(), "garbage{");
1632 }
1633
1634 #[test]
1635 fn metadata_round_trip_and_preserved_across_refresh_write() {
1636 let dir = tempfile::tempdir().unwrap();
1637 let path = dir.path().join("auth.json");
1638 let cred = named(OAuthProviderId::Anthropic, "work");
1639 save_provider_auth_at(&path, &cred.storage_key(), &creds("r1", None)).unwrap();
1640 save_account_metadata_at(
1641 &path,
1642 &cred,
1643 &AccountMetadata {
1644 identity: Some("me@example.com".into()),
1645 added_at: Some(1234),
1646 },
1647 )
1648 .unwrap();
1649 save_provider_auth_at(&path, &cred.storage_key(), &creds("r2", None)).unwrap();
1651 let root = read_root(&path);
1652 assert_eq!(root["anthropic@work"]["label"], "work");
1653 assert_eq!(root["anthropic@work"]["identity"], "me@example.com");
1654 assert_eq!(root["anthropic@work"]["addedAt"], 1234);
1655 assert_eq!(root["anthropic@work"]["refresh"], "r2");
1656 assert!(save_account_metadata_at(
1658 &path,
1659 &named(OAuthProviderId::Anthropic, "nope"),
1660 &AccountMetadata::default()
1661 )
1662 .is_err());
1663 assert!(read_root(&path).get("anthropic@nope").is_none());
1664 }
1665
1666 #[test]
1667 fn set_slot_identity_sets_only_identity_fields_on_existing_slot() {
1668 let dir = tempfile::tempdir().unwrap();
1669 let path = dir.path().join("auth.json");
1670 std::fs::write(
1673 &path,
1674 r#"{
1675 "anthropic@claude1": {"type":"oauth","refresh":"R-claude1","access":"A-claude1","expires":1234567,"label":"claude1","addedAt":99,"identity":"old@example.com","metadata":{"tenant":"t1"}},
1676 "anthropic": {"type":"oauth","refresh":"R-default","access":"A-default","expires":7},
1677 "groq": {"type":"api_key","key":"gsk-secret"}
1678 }"#,
1679 )
1680 .unwrap();
1681 let before = read_root(&path);
1682 let cred = named(OAuthProviderId::Anthropic, "claude1");
1683
1684 set_slot_identity_at(
1685 &path,
1686 &cred,
1687 " b8a1448d-0000-4000-8000-00000000000a ",
1688 Some(" jr@example.com "),
1689 )
1690 .unwrap();
1691 let after = read_root(&path);
1692 let slot = after["anthropic@claude1"].as_object().unwrap();
1693 assert_eq!(
1694 slot["accountId"], "b8a1448d-0000-4000-8000-00000000000a",
1695 "trimmed"
1696 );
1697 assert_eq!(slot["identity"], "jr@example.com", "trimmed");
1698 for field in [
1700 "type", "refresh", "access", "expires", "label", "addedAt", "metadata",
1701 ] {
1702 assert_eq!(
1703 slot.get(field),
1704 before["anthropic@claude1"].get(field),
1705 "{field} must be untouched"
1706 );
1707 }
1708 assert_eq!(
1709 slot.len(),
1710 before["anthropic@claude1"].as_object().unwrap().len() + 1,
1711 "exactly one new key (accountId); identity was overwritten in place"
1712 );
1713 assert_eq!(after["anthropic"], before["anthropic"]);
1715 assert_eq!(after["groq"], before["groq"]);
1716 assert_eq!(after.len(), before.len());
1717 let inv = list_accounts_detailed_at(&path, Some(OAuthProviderId::Anthropic)).unwrap();
1719 let row = inv.accounts.iter().find(|a| a.label == "claude1").unwrap();
1720 assert_eq!(row.account_id_prefix.as_deref(), Some("b8a1448d"));
1721 assert_eq!(row.identity.as_deref(), Some("jr@example.com"));
1722 assert!(row.seat_fingerprint.is_some());
1723
1724 set_slot_identity_at(&path, &cred, "other-seat", None).unwrap();
1726 let slot = &read_root(&path)["anthropic@claude1"];
1727 assert_eq!(slot["accountId"], "other-seat");
1728 assert_eq!(slot["identity"], "jr@example.com");
1729 set_slot_identity_at(&path, &cred, "other-seat", Some(" ")).unwrap();
1731 assert_eq!(
1732 read_root(&path)["anthropic@claude1"]["identity"],
1733 "jr@example.com"
1734 );
1735
1736 set_slot_identity_at(
1738 &path,
1739 &CredentialRef::default_for(OAuthProviderId::Anthropic),
1740 "other-seat",
1741 None,
1742 )
1743 .unwrap();
1744 let inv = list_accounts_detailed_at(&path, Some(OAuthProviderId::Anthropic)).unwrap();
1745 assert_eq!(
1746 inv.duplicate_identity_keys,
1747 vec!["anthropic".to_string(), "anthropic@claude1".to_string()]
1748 );
1749 assert_eq!(read_root(&path)["anthropic"]["refresh"], "R-default");
1750 }
1751
1752 #[test]
1753 fn set_slot_identity_fails_closed_and_writes_nothing() {
1754 let dir = tempfile::tempdir().unwrap();
1755 let path = dir.path().join("auth.json");
1756 let cred = named(OAuthProviderId::Anthropic, "claude1");
1757 assert!(set_slot_identity_at(&path, &cred, "seat", None).is_err());
1759 assert!(!path.exists());
1760 assert!(!path.with_extension("json.lock").exists());
1761
1762 std::fs::write(
1763 &path,
1764 r#"{"anthropic":{"type":"oauth","refresh":"R","access":"A","expires":1},"groq":{"type":"api_key","key":"k"}}"#,
1765 )
1766 .unwrap();
1767 let before = std::fs::read_to_string(&path).unwrap();
1768 assert!(set_slot_identity_at(&path, &cred, "seat", Some("x@example.com")).is_err());
1770 assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
1771 assert!(
1772 read_root(&path).get("anthropic@claude1").is_none(),
1773 "no phantom slot"
1774 );
1775 let default = CredentialRef::default_for(OAuthProviderId::Anthropic);
1777 assert!(set_slot_identity_at(&path, &default, "", None).is_err());
1778 assert!(set_slot_identity_at(&path, &default, " ", Some("x@example.com")).is_err());
1779 assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
1780 assert!(read_root(&path)["anthropic"].get("accountId").is_none());
1781 std::fs::write(&path, "{{not json").unwrap();
1783 assert!(set_slot_identity_at(&path, &default, "seat", None).is_err());
1784 assert_eq!(std::fs::read_to_string(&path).unwrap(), "{{not json");
1785 }
1786
1787 #[test]
1788 fn cas_save_saved_removed_replaced() {
1789 let dir = tempfile::tempdir().unwrap();
1790 let path = dir.path().join("auth.json");
1791 let key = "openai-codex@astra2";
1792 save_provider_auth_at(&path, key, &creds("old", Some("acct-1"))).unwrap();
1793
1794 let rotated = creds("old-rotated", None);
1796 assert_eq!(
1797 save_provider_auth_if_refresh_matches_at(&path, key, "old", &rotated).unwrap(),
1798 CasOutcome::Saved
1799 );
1800 let root = read_root(&path);
1801 assert_eq!(root[key]["refresh"], "old-rotated");
1802 assert_eq!(root[key]["accountId"], "acct-1", "accountId preserved");
1803
1804 save_provider_auth_at(&path, key, &creds("fresh-login", Some("acct-9"))).unwrap();
1806 assert_eq!(
1807 save_provider_auth_if_refresh_matches_at(&path, key, "old-rotated", &creds("stale", None))
1808 .unwrap(),
1809 CasOutcome::Replaced
1810 );
1811 assert_eq!(read_root(&path)[key]["refresh"], "fresh-login");
1812
1813 remove_key_at(&path, key).unwrap();
1815 assert_eq!(
1816 save_provider_auth_if_refresh_matches_at(&path, key, "fresh-login", &creds("x", None))
1817 .unwrap(),
1818 CasOutcome::Removed
1819 );
1820 assert!(read_root(&path).get(key).is_none());
1821 std::fs::remove_file(&path).unwrap();
1824 assert_eq!(
1825 save_provider_auth_if_refresh_matches_at(&path, key, "x", &creds("x", None)).unwrap(),
1826 CasOutcome::StoreMissing
1827 );
1828 assert!(!path.exists());
1829 }
1830
1831 #[test]
1832 fn locked_login_persist_refuses_duplicate_seat_and_writes_metadata_atomically() {
1833 let dir = tempfile::tempdir().unwrap();
1834 let path = dir.path().join("auth.json");
1835 save_provider_auth_at(&path, "openai-codex", &creds("r0", Some("acct-A"))).unwrap();
1836 let astra2 = named(OAuthProviderId::OpenAiCodex, "astra2");
1837 let meta = AccountMetadata {
1838 identity: Some("a@example.com".into()),
1839 added_at: Some(100),
1840 };
1841 let out = save_credential_unless_duplicate_at(&path, &astra2, &creds("rx", Some("acct-A")), &meta)
1843 .unwrap();
1844 assert_eq!(out.evidence, IdentityEvidence::Duplicate(Account::Default));
1845 assert!(!out.saved);
1846 assert!(read_root(&path).get("openai-codex@astra2").is_none());
1847 let out = save_credential_unless_duplicate_at(&path, &astra2, &creds("r0", None), &meta).unwrap();
1849 assert_eq!(out.evidence, IdentityEvidence::Duplicate(Account::Default));
1850 assert!(!out.saved);
1851 let out = save_credential_unless_duplicate_at(&path, &astra2, &creds("r2", Some("acct-B")), &meta)
1853 .unwrap();
1854 assert_eq!(out.evidence, IdentityEvidence::NoDuplicate);
1855 assert!(out.saved);
1856 let root = read_root(&path);
1857 assert_eq!(root["openai-codex@astra2"]["label"], "astra2");
1858 assert_eq!(root["openai-codex@astra2"]["identity"], "a@example.com");
1859 assert_eq!(root["openai-codex@astra2"]["addedAt"], 100);
1860 assert_eq!(root["openai-codex"]["refresh"], "r0", "default slot untouched");
1861 let out = save_credential_unless_duplicate_at(
1864 &path,
1865 &astra2,
1866 &creds("r3", None),
1867 &AccountMetadata {
1868 identity: None,
1869 added_at: Some(200),
1870 },
1871 )
1872 .unwrap();
1873 assert_eq!(out.evidence, IdentityEvidence::NoEvidence);
1874 assert!(out.saved);
1875 let root = read_root(&path);
1876 assert!(root["openai-codex@astra2"].get("identity").is_none());
1877 assert!(root["openai-codex@astra2"].get("accountId").is_none());
1878 assert_eq!(root["openai-codex@astra2"]["addedAt"], 200);
1879 assert_eq!(root["openai-codex@astra2"]["refresh"], "r3");
1880 std::fs::write(&path, "{{not json").unwrap();
1882 assert!(save_credential_unless_duplicate_at(&path, &astra2, &creds("r4", None), &meta).is_err());
1883 assert_eq!(std::fs::read_to_string(&path).unwrap(), "{{not json");
1884 assert!(save_credential_at(&path, &astra2, &creds("r4", None)).is_err());
1885 assert_eq!(std::fs::read_to_string(&path).unwrap(), "{{not json");
1886 }
1887
1888 #[test]
1889 fn inventory_flags_duplicate_seats() {
1890 let dir = tempfile::tempdir().unwrap();
1891 let path = dir.path().join("auth.json");
1892 save_provider_auth_at(&path, "openai-codex", &creds("r0", Some("acct-A"))).unwrap();
1893 save_provider_auth_at(&path, "openai-codex@b", &creds("rb", Some("acct-A"))).unwrap();
1894 save_provider_auth_at(&path, "openai-codex@c", &creds("rc", Some("acct-C"))).unwrap();
1895 let inv = list_accounts_detailed_at(&path, Some(OAuthProviderId::OpenAiCodex)).unwrap();
1896 assert_eq!(
1897 inv.duplicate_identity_keys,
1898 vec!["openai-codex".to_string(), "openai-codex@b".to_string()]
1899 );
1900 }
1901
1902 #[test]
1903 fn duplicate_identity_detects_same_seat_under_other_label() {
1904 let dir = tempfile::tempdir().unwrap();
1905 let path = dir.path().join("auth.json");
1906 save_provider_auth_at(&path, "openai-codex", &creds("r0", Some("acct-A"))).unwrap();
1907 save_provider_auth_at(&path, "openai-codex@b", &creds("rb", Some("acct-B"))).unwrap();
1908 save_provider_auth_at(&path, "anthropic@x", &creds("rx", Some("acct-A"))).unwrap();
1909 let dup = find_duplicate_identity_at(
1910 &path,
1911 OAuthProviderId::OpenAiCodex,
1912 "acct-A",
1913 &Account::named("new").unwrap(),
1914 )
1915 .unwrap();
1916 assert_eq!(dup, Some(Account::Default));
1917 assert_eq!(
1919 find_duplicate_identity_at(&path, OAuthProviderId::OpenAiCodex, "acct-A", &Account::Default)
1920 .unwrap(),
1921 None
1922 );
1923 assert_eq!(
1925 find_duplicate_identity_at(&path, OAuthProviderId::OpenAiCodex, "acct-Z", &Account::Default)
1926 .unwrap(),
1927 None
1928 );
1929 assert_eq!(
1930 find_duplicate_identity_at(&path, OAuthProviderId::OpenAiCodex, "", &Account::Default)
1931 .unwrap(),
1932 None
1933 );
1934 }
1935}
1936
1937#[cfg(test)]
1940mod store_lock_wait_tests {
1941 use super::*;
1942 use std::time::{Duration, Instant};
1943
1944 fn hold_store_lock(path: &Path) -> std::fs::File {
1945 use fs4::fs_std::FileExt;
1946 let lock = std::fs::OpenOptions::new()
1947 .create(true)
1948 .truncate(false)
1949 .write(true)
1950 .open(path.with_extension("json.lock"))
1951 .unwrap();
1952 assert!(FileExt::try_lock_exclusive(&lock).unwrap());
1953 lock
1954 }
1955
1956 #[test]
1957 fn store_lock_wait_has_a_deadline() {
1958 let dir = tempfile::tempdir().unwrap();
1959 let path = dir.path().join("auth.json");
1960 std::fs::write(&path, "{}").unwrap();
1961 let _held = hold_store_lock(&path);
1962 let started = Instant::now();
1963 let err = with_locked_root_typed(&path, false, Duration::from_millis(200), |_| {
1964 Ok((false, ()))
1965 })
1966 .unwrap_err();
1967 assert!(matches!(err, StoreError::Busy(_)), "{err:?}");
1968 assert!(started.elapsed() < Duration::from_secs(5));
1969 assert!(String::from(err).contains("auth.json.lock"));
1970 }
1971
1972 #[test]
1973 fn store_lock_is_taken_once_free() {
1974 let dir = tempfile::tempdir().unwrap();
1975 let path = dir.path().join("auth.json");
1976 std::fs::write(&path, "{}").unwrap();
1977 let held = hold_store_lock(&path);
1978 let release = std::thread::spawn(move || {
1979 std::thread::sleep(Duration::from_millis(100));
1980 drop(held);
1981 });
1982 with_locked_root_typed(&path, false, Duration::from_secs(10), |_| Ok((false, ())))
1983 .expect("lock is acquired after the holder lets go");
1984 release.join().unwrap();
1985 }
1986
1987 #[test]
1988 fn production_store_lock_wait_is_bounded() {
1989 assert!(STORE_LOCK_WAIT >= Duration::from_secs(5));
1990 assert!(STORE_LOCK_WAIT <= Duration::from_secs(30));
1991 }
1992}
1993
1994#[cfg(test)]
1997mod corrupt_store_error_tests {
1998 use super::*;
1999
2000 fn assert_actionable(msg: &str, path: &Path) {
2001 let p = path.display().to_string();
2002 assert!(msg.contains(&format!("mv '{p}' '{p}.broken-")), "{msg}");
2003 assert!(msg.contains("not valid JSON"), "{msg}");
2004 assert!(!msg.contains("synaps login"), "{msg}");
2005 }
2006
2007 #[test]
2008 fn corrupt_store_error_names_the_file_and_the_mv_command() {
2009 let dir = tempfile::tempdir().unwrap();
2010 let path = dir.path().join("auth.json");
2011 std::fs::write(&path, "{\"anthropic\":{").unwrap();
2012 let cred = CredentialRef::parse_storage_key("anthropic@work").unwrap();
2013 let creds = OAuthCredentials {
2014 auth_type: "oauth".into(),
2015 refresh: "r".into(),
2016 access: "a".into(),
2017 expires: 1,
2018 account_id: None,
2019 };
2020 assert_actionable(&save_provider_auth_at(&path, "kimi-code", &creds).unwrap_err(), &path);
2022 assert_actionable(&save_credential_at(&path, &cred, &creds).unwrap_err(), &path);
2023 for err in [
2025 save_provider_auth_typed_at(&path, "muse", &creds).unwrap_err(),
2026 load_provider_auth_typed_at(&path, "anthropic").unwrap_err(),
2027 ] {
2028 let StoreError::Corrupt(msg) = err else {
2029 panic!("expected Corrupt, got {err:?}");
2030 };
2031 assert_actionable(&msg, &path);
2032 }
2033 assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"anthropic\":{");
2034 }
2035
2036 #[test]
2037 fn shell_quoting_survives_a_quote_in_the_path() {
2038 assert_eq!(shell_quote("/a/it's/auth.json"), r#"'/a/it'\''s/auth.json'"#);
2039 }
2040}
2041
2042#[cfg(test)]
2045mod remove_sidecar_tests {
2046 use super::*;
2047
2048 #[test]
2049 fn removing_a_slot_deletes_its_refresh_sidecar() {
2050 let dir = tempfile::tempdir().unwrap();
2051 let path = dir.path().join("auth.json");
2052 std::fs::write(&path, r#"{"anthropic@work":{"type":"oauth"},"anthropic":{"type":"oauth"}}"#)
2053 .unwrap();
2054 let work = refresh_pending_path(&path, "anthropic@work");
2055 let other = refresh_pending_path(&path, "anthropic");
2056 assert_eq!(
2057 work,
2058 dir.path().join("auth.json.anthropic@work.refresh.pending")
2059 );
2060 std::fs::write(&work, "{}").unwrap();
2061 std::fs::write(&other, "{}").unwrap();
2062 assert!(remove_key_at(&path, "anthropic@work").unwrap());
2063 assert!(!work.exists(), "removed slot's sidecar is gone");
2064 assert!(other.exists(), "other slots keep theirs");
2065 std::fs::write(&work, "{}").unwrap();
2067 assert!(!remove_key_at(&path, "anthropic@work").unwrap());
2068 assert!(!work.exists());
2069 }
2070}