1use parking_lot::RwLock;
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use std::path::PathBuf;
19use std::sync::{Arc, OnceLock};
20
21#[derive(Clone, Serialize, Deserialize)]
27#[serde(tag = "type", rename_all = "snake_case")]
28pub enum AuthCredential {
29 ApiKey {
31 key: String,
33 },
34 OAuth {
36 access_token: String,
38 refresh_token: Option<String>,
40 expires_at: u64,
42 #[serde(default)]
44 scopes: Option<String>,
45 #[serde(default)]
47 provider_data: Option<serde_json::Value>,
48 },
49 Session {
51 token: String,
53 #[serde(default)]
55 expires_at: u64,
56 #[serde(default)]
58 metadata: Option<serde_json::Value>,
59 },
60}
61
62impl std::fmt::Debug for AuthCredential {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 match self {
71 AuthCredential::ApiKey { .. } => f
72 .debug_struct("AuthCredential::ApiKey")
73 .field("key", &"<redacted>")
74 .finish(),
75 AuthCredential::OAuth {
76 expires_at,
77 refresh_token,
78 ..
79 } => f
80 .debug_struct("AuthCredential::OAuth")
81 .field("access_token", &"<redacted>")
82 .field(
83 "refresh_token",
84 &if refresh_token.is_some() {
85 "<redacted>"
86 } else {
87 "None"
88 },
89 )
90 .field("expires_at", expires_at)
91 .finish(),
92 AuthCredential::Session { expires_at, .. } => f
93 .debug_struct("AuthCredential::Session")
94 .field("token", &"<redacted>")
95 .field("expires_at", expires_at)
96 .finish(),
97 }
98 }
99}
100
101impl AuthCredential {
102 pub fn is_expired(&self) -> bool {
104 match self {
105 AuthCredential::OAuth { expires_at, .. } => {
106 let now = now_secs();
107 *expires_at < now
108 }
109 AuthCredential::Session { expires_at, .. } => {
110 if *expires_at == 0 {
111 return false; }
113 *expires_at <= now_secs()
114 }
115 AuthCredential::ApiKey { .. } => false,
116 }
117 }
118
119 pub fn needs_refresh(&self) -> bool {
121 match self {
122 AuthCredential::OAuth {
123 expires_at,
124 refresh_token,
125 ..
126 } => {
127 let now = now_secs();
128 refresh_token.is_some() && *expires_at <= now + 60
129 }
130 AuthCredential::Session { .. } => false,
131 AuthCredential::ApiKey { .. } => false,
132 }
133 }
134
135 pub fn access_token(&self) -> Option<&str> {
137 match self {
138 AuthCredential::OAuth { access_token, .. } if !self.is_expired() => Some(access_token),
139 AuthCredential::Session { token, .. } if !self.is_expired() => Some(token),
140 _ => None,
141 }
142 }
143
144 pub fn type_name(&self) -> &'static str {
146 match self {
147 AuthCredential::ApiKey { .. } => "api_key",
148 AuthCredential::OAuth { .. } => "oauth",
149 AuthCredential::Session { .. } => "session",
150 }
151 }
152
153 pub fn validate(&self) -> Result<(), CredentialValidationError> {
155 match self {
156 AuthCredential::ApiKey { key } => {
157 if key.is_empty() {
158 return Err(CredentialValidationError::EmptyField("key".to_string()));
159 }
160 if key == "your-api-key-here" || key == "xxx" {
162 return Err(CredentialValidationError::PlaceholderValue(key.clone()));
163 }
164 Ok(())
165 }
166 AuthCredential::OAuth {
167 access_token,
168 expires_at,
169 ..
170 } => {
171 if access_token.is_empty() {
172 return Err(CredentialValidationError::EmptyField(
173 "access_token".to_string(),
174 ));
175 }
176 if *expires_at == 0 {
177 return Err(CredentialValidationError::InvalidExpiry);
178 }
179 Ok(())
180 }
181 AuthCredential::Session { token, .. } => {
182 if token.is_empty() {
183 return Err(CredentialValidationError::EmptyField("token".to_string()));
184 }
185 Ok(())
186 }
187 }
188 }
189}
190
191#[derive(Debug, Clone, thiserror::Error)]
193pub enum CredentialValidationError {
194 #[error("Field '{0}' must not be empty")]
195 EmptyField(String),
197 #[error("Placeholder value detected: '{0}'")]
198 PlaceholderValue(String),
200 #[error("Invalid expiry timestamp")]
201 InvalidExpiry,
203}
204
205#[derive(Debug, Clone)]
211pub struct AuthStatus {
212 pub configured: bool,
214 pub source: Option<String>,
216 pub label: Option<String>,
218}
219
220impl std::fmt::Display for AuthStatus {
221 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222 match (&self.source, &self.label) {
223 (Some(source), Some(label)) => write!(f, "{} ({})", source, label),
224 (Some(source), None) => write!(f, "{}", source),
225 (None, Some(label)) => write!(f, "{}", label),
226 (None, None) => write!(f, "not configured"),
227 }
228 }
229}
230
231pub type AuthResult<T> = Result<T, AuthError>;
237
238#[derive(Debug, Clone, thiserror::Error)]
240pub enum AuthError {
241 #[error("Failed to read auth storage: {0}")]
242 ReadError(String),
244 #[error("Failed to write auth storage: {0}")]
245 WriteError(String),
247 #[error("Credential not found: {0}")]
248 NotFound(String),
250 #[error("Invalid credential format: {0}")]
251 InvalidFormat(String),
253 #[error("Keyring error: {0}")]
254 KeyringError(String),
256 #[error("Credential validation failed: {0}")]
257 ValidationFailed(String),
259}
260
261pub trait AuthStorageBackend: Send + Sync {
267 fn read(&self) -> AuthResult<Option<String>>;
269 fn write(&self, data: &str) -> AuthResult<()>;
271 fn delete(&self) -> AuthResult<()>;
273}
274
275pub struct FileAuthStorage {
285 path: PathBuf,
286 legacy_read_path: Option<PathBuf>,
289 cache: RwLock<Option<String>>,
290}
291
292impl FileAuthStorage {
293 pub fn new(path: PathBuf) -> Self {
295 Self {
296 path,
297 legacy_read_path: None,
298 cache: RwLock::new(None),
299 }
300 }
301
302 pub fn with_default_paths() -> Option<Self> {
306 Some(Self::with_legacy_fallback(
307 Self::default_path()?,
308 Self::legacy_default_path(),
309 ))
310 }
311
312 pub fn with_legacy_fallback(path: PathBuf, legacy_read_path: Option<PathBuf>) -> Self {
315 Self {
316 path,
317 legacy_read_path,
318 cache: RwLock::new(None),
319 }
320 }
321
322 pub fn default_path() -> Option<PathBuf> {
324 oxicode_catalog::oxi_home::oxicode_home().map(|h| h.join("auth.json"))
325 }
326
327 pub fn legacy_default_path() -> Option<PathBuf> {
330 oxicode_catalog::oxi_home::legacy_home_dir().map(|h| h.join("auth.json"))
331 }
332
333 pub fn path(&self) -> &PathBuf {
335 &self.path
336 }
337}
338
339impl AuthStorageBackend for FileAuthStorage {
340 fn read(&self) -> AuthResult<Option<String>> {
341 let read_path = if self.path.exists() {
344 &self.path
345 } else if let Some(legacy) = self.legacy_read_path.as_ref().filter(|p| p.exists()) {
346 legacy
347 } else {
348 return Ok(None);
349 };
350
351 match std::fs::read_to_string(read_path) {
352 Ok(content) => {
353 *self.cache.write() = Some(content.clone());
354 Ok(Some(content))
355 }
356 Err(e) => Err(AuthError::ReadError(e.to_string())),
357 }
358 }
359
360 fn write(&self, data: &str) -> AuthResult<()> {
361 if let Some(parent) = self.path.parent() {
363 std::fs::create_dir_all(parent).map_err(|e| AuthError::WriteError(e.to_string()))?;
364 #[cfg(unix)]
365 {
366 use std::os::unix::fs::PermissionsExt;
367 let perms = std::fs::Permissions::from_mode(0o700);
368 let _ = std::fs::set_permissions(parent, perms);
369 }
370 }
371
372 std::fs::write(&self.path, data).map_err(|e| AuthError::WriteError(e.to_string()))?;
374
375 #[cfg(unix)]
377 {
378 use std::os::unix::fs::PermissionsExt;
379 let perms = std::fs::Permissions::from_mode(0o600);
380 std::fs::set_permissions(&self.path, perms)
381 .map_err(|e| AuthError::WriteError(e.to_string()))?;
382 }
383
384 *self.cache.write() = Some(data.to_string());
385 Ok(())
386 }
387
388 fn delete(&self) -> AuthResult<()> {
389 if self.path.exists() {
390 std::fs::remove_file(&self.path).map_err(|e| AuthError::WriteError(e.to_string()))?;
391 }
392 *self.cache.write() = None;
393 Ok(())
394 }
395}
396
397pub struct MemoryAuthStorage {
403 data: RwLock<HashMap<String, AuthCredential>>,
404}
405
406impl MemoryAuthStorage {
407 pub fn new() -> Self {
409 Self {
410 data: RwLock::new(HashMap::new()),
411 }
412 }
413}
414
415impl Default for MemoryAuthStorage {
416 fn default() -> Self {
417 Self::new()
418 }
419}
420
421impl AuthStorageBackend for MemoryAuthStorage {
422 fn read(&self) -> AuthResult<Option<String>> {
423 Ok(None)
425 }
426
427 fn write(&self, _data: &str) -> AuthResult<()> {
428 Ok(())
429 }
430
431 fn delete(&self) -> AuthResult<()> {
432 self.data.write().clear();
433 Ok(())
434 }
435}
436
437pub trait FallbackResolver: Send + Sync {
443 fn resolve(&self, provider: &str) -> Option<String>;
445}
446
447pub struct FnFallbackResolver {
449 #[allow(clippy::type_complexity)]
450 f: Box<dyn Fn(&str) -> Option<String> + Send + Sync>,
451}
452
453impl FnFallbackResolver {
454 #[allow(clippy::type_complexity)]
456 pub fn new(f: Box<dyn Fn(&str) -> Option<String> + Send + Sync>) -> Self {
457 Self { f }
458 }
459}
460
461impl FallbackResolver for FnFallbackResolver {
462 fn resolve(&self, provider: &str) -> Option<String> {
463 (self.f)(provider)
464 }
465}
466
467pub struct EnvVarFallbackResolver;
472
473impl FallbackResolver for EnvVarFallbackResolver {
474 fn resolve(&self, provider: &str) -> Option<String> {
475 let builtin = oxicode_ai::get_builtin_provider(provider)?;
477 let key = builtin.env_key;
478
479 if let Ok(val) = std::env::var(key)
481 && !val.is_empty()
482 {
483 return Some(val);
484 }
485
486 for extra in builtin.extra_env_keys {
488 if let Ok(val) = std::env::var(extra)
489 && !val.is_empty()
490 {
491 return Some(val);
492 }
493 }
494
495 None
496 }
497}
498
499pub struct AuthStorage {
513 file_storage: Option<Arc<dyn AuthStorageBackend>>,
515 credentials: RwLock<HashMap<String, AuthCredential>>,
517 runtime_overrides: RwLock<HashMap<String, String>>,
519 fallback_resolver: RwLock<Option<Arc<dyn FallbackResolver>>>,
521 errors: RwLock<Vec<AuthError>>,
523 load_error: RwLock<Option<AuthError>>,
525 plaintext_warned: OnceLock<()>,
527}
528
529impl AuthStorage {
530 pub fn new() -> Self {
532 let file_storage = FileAuthStorage::with_default_paths()
533 .map(|s| Arc::new(s) as Arc<dyn AuthStorageBackend>);
534
535 let credentials = if let Some(ref storage) = file_storage {
536 match storage.read() {
537 Ok(Some(content)) => serde_json::from_str(&content).unwrap_or_default(),
538 _ => HashMap::new(),
539 }
540 } else {
541 HashMap::new()
542 };
543
544 Self {
545 file_storage,
546 credentials: RwLock::new(credentials),
547 runtime_overrides: RwLock::new(HashMap::new()),
548 fallback_resolver: RwLock::new(None),
549 errors: RwLock::new(Vec::new()),
550 load_error: RwLock::new(None),
551 plaintext_warned: OnceLock::new(),
552 }
553 }
554
555 pub fn with_backend(backend: impl AuthStorageBackend + 'static) -> Self {
557 let credentials = match backend.read() {
558 Ok(Some(content)) => serde_json::from_str(&content).unwrap_or_default(),
559 _ => HashMap::new(),
560 };
561
562 Self {
563 file_storage: Some(Arc::new(backend)),
564 credentials: RwLock::new(credentials),
565 runtime_overrides: RwLock::new(HashMap::new()),
566 fallback_resolver: RwLock::new(None),
567 errors: RwLock::new(Vec::new()),
568 load_error: RwLock::new(None),
569 plaintext_warned: OnceLock::new(),
570 }
571 }
572
573 pub fn in_memory() -> Self {
575 Self {
576 file_storage: None,
577 credentials: RwLock::new(HashMap::new()),
578 runtime_overrides: RwLock::new(HashMap::new()),
579 fallback_resolver: RwLock::new(None),
580 errors: RwLock::new(Vec::new()),
581 load_error: RwLock::new(None),
582 plaintext_warned: OnceLock::new(),
583 }
584 }
585
586 pub fn default_path() -> Option<PathBuf> {
588 FileAuthStorage::default_path()
589 }
590
591 pub fn set_runtime_key(&self, provider: &str, api_key: String) {
597 self.runtime_overrides
598 .write()
599 .insert(provider.to_string(), api_key);
600 }
601
602 pub fn remove_runtime_key(&self, provider: &str) {
604 self.runtime_overrides.write().remove(provider);
605 }
606
607 pub fn set_fallback_resolver(&self, resolver: Arc<dyn FallbackResolver>) {
614 *self.fallback_resolver.write() = Some(resolver);
615 }
616
617 pub fn clear_fallback_resolver(&self) {
619 *self.fallback_resolver.write() = None;
620 }
621
622 pub fn has_auth(&self, provider: &str) -> bool {
628 if self.runtime_overrides.read().contains_key(provider) {
629 return true;
630 }
631 if self.credentials.read().contains_key(provider) {
632 return true;
633 }
634 if let Some(ref resolver) = *self.fallback_resolver.read()
635 && resolver.resolve(provider).is_some()
636 {
637 return true;
638 }
639 false
640 }
641
642 pub fn get_status(&self, provider: &str) -> AuthStatus {
644 if self.runtime_overrides.read().contains_key(provider) {
645 return AuthStatus {
646 configured: false,
647 source: Some("runtime".to_string()),
648 label: Some("--api-key".to_string()),
649 };
650 }
651
652 if let Some(cred) = self.credentials.read().get(provider) {
653 return AuthStatus {
654 configured: true,
655 source: Some("stored".to_string()),
656 label: Some(cred.type_name().to_string()),
657 };
658 }
659
660 if let Some(ref resolver) = *self.fallback_resolver.read()
661 && resolver.resolve(provider).is_some()
662 {
663 return AuthStatus {
664 configured: false,
665 source: Some("fallback".to_string()),
666 label: Some("custom provider config".to_string()),
667 };
668 }
669
670 AuthStatus {
671 configured: false,
672 source: None,
673 label: None,
674 }
675 }
676
677 pub fn get_api_key(&self, provider: &str) -> Option<String> {
686 self.get_api_key_with_options(provider, true)
687 }
688
689 pub fn get_api_key_with_options(
691 &self,
692 provider: &str,
693 include_fallback: bool,
694 ) -> Option<String> {
695 if let Some(key) = self.runtime_overrides.read().get(provider) {
697 return Some(key.clone());
698 }
699
700 if let Some(cred) = self.credentials.read().get(provider) {
702 return match cred {
703 AuthCredential::ApiKey { key } => Some(key.clone()),
704 AuthCredential::OAuth {
705 access_token,
706 expires_at,
707 ..
708 } => {
709 if *expires_at > now_secs() {
710 Some(access_token.clone())
711 } else {
712 None
714 }
715 }
716 AuthCredential::Session {
717 token, expires_at, ..
718 } => {
719 if *expires_at == 0 || *expires_at > now_secs() {
720 Some(token.clone())
721 } else {
722 None
723 }
724 }
725 };
726 }
727
728 if let Some(builtin) = oxicode_ai::register_builtins::get_builtin_provider(provider) {
733 let env_key = builtin.env_key;
734 let credentials = self.credentials.read();
735 for other in oxicode_ai::register_builtins::get_builtin_providers() {
736 if other.name == provider {
737 continue; }
739 if other.env_key == env_key
740 && let Some(cred) = credentials.get(other.name)
741 {
742 return match cred {
743 AuthCredential::ApiKey { key } => Some(key.clone()),
744 AuthCredential::OAuth {
745 access_token,
746 expires_at,
747 ..
748 } => {
749 if *expires_at > now_secs() {
750 Some(access_token.clone())
751 } else {
752 None
753 }
754 }
755 AuthCredential::Session {
756 token, expires_at, ..
757 } => {
758 if *expires_at == 0 || *expires_at > now_secs() {
759 Some(token.clone())
760 } else {
761 None
762 }
763 }
764 };
765 }
766 }
767 }
768
769 if include_fallback && let Some(ref resolver) = *self.fallback_resolver.read() {
771 return resolver.resolve(provider);
772 }
773
774 None
775 }
776
777 pub fn set_api_key(&self, provider: &str, key: String) {
783 self.credentials
784 .write()
785 .insert(provider.to_string(), AuthCredential::ApiKey { key });
786 if let Err(e) = self.persist() {
787 tracing::warn!("Failed to persist API key for '{}': {}", provider, e);
788 }
789 }
790
791 pub fn set_oauth(
793 &self,
794 provider: &str,
795 access_token: String,
796 refresh_token: Option<String>,
797 expires_at: u64,
798 ) {
799 self.set_oauth_full(
800 provider,
801 access_token,
802 refresh_token,
803 expires_at,
804 None,
805 None,
806 );
807 }
808
809 pub fn set_oauth_full(
811 &self,
812 provider: &str,
813 access_token: String,
814 refresh_token: Option<String>,
815 expires_at: u64,
816 scopes: Option<String>,
817 provider_data: Option<serde_json::Value>,
818 ) {
819 self.credentials.write().insert(
820 provider.to_string(),
821 AuthCredential::OAuth {
822 access_token,
823 refresh_token,
824 expires_at,
825 scopes,
826 provider_data,
827 },
828 );
829 if let Err(e) = self.persist() {
830 tracing::warn!("Failed to persist OAuth token for '{}': {}", provider, e);
831 }
832 }
833
834 pub fn set_session(
836 &self,
837 provider: &str,
838 token: String,
839 expires_at: u64,
840 metadata: Option<serde_json::Value>,
841 ) {
842 self.credentials.write().insert(
843 provider.to_string(),
844 AuthCredential::Session {
845 token,
846 expires_at,
847 metadata,
848 },
849 );
850 if let Err(e) = self.persist() {
851 tracing::warn!("Failed to persist session for '{}': {}", provider, e);
852 }
853 }
854
855 pub fn update_oauth_tokens(
857 &self,
858 provider: &str,
859 new_access_token: String,
860 new_refresh_token: Option<String>,
861 new_expires_at: u64,
862 ) -> AuthResult<()> {
863 let mut creds = self.credentials.write();
864 let cred = creds
865 .get_mut(provider)
866 .ok_or_else(|| AuthError::NotFound(provider.to_string()))?;
867
868 match cred {
869 AuthCredential::OAuth {
870 access_token,
871 refresh_token,
872 expires_at,
873 ..
874 } => {
875 *access_token = new_access_token;
876 *refresh_token = new_refresh_token;
877 *expires_at = new_expires_at;
878 }
879 _ => {
880 return Err(AuthError::InvalidFormat(format!(
881 "Provider '{}' does not have OAuth credentials",
882 provider
883 )));
884 }
885 }
886
887 drop(creds);
888 if let Err(e) = self.persist() {
889 tracing::warn!(
890 "Failed to persist OAuth token update for '{}': {}",
891 provider,
892 e
893 );
894 }
895 Ok(())
896 }
897
898 pub fn get(&self, provider: &str) -> Option<AuthCredential> {
904 self.credentials.read().get(provider).cloned()
905 }
906
907 pub fn get_oauth_credential(&self, provider: &str) -> Option<AuthCredential> {
909 self.credentials.read().get(provider).cloned()
910 }
911
912 pub fn has_oauth_with_refresh(&self, provider: &str) -> bool {
914 if let Some(cred) = self.credentials.read().get(provider) {
915 matches!(
916 cred,
917 AuthCredential::OAuth {
918 refresh_token: Some(_),
919 ..
920 }
921 )
922 } else {
923 false
924 }
925 }
926
927 pub fn set(&self, provider: &str, credential: AuthCredential) {
933 self.credentials
934 .write()
935 .insert(provider.to_string(), credential);
936 if let Err(e) = self.persist() {
937 tracing::warn!("Failed to persist credential for '{}': {}", provider, e);
938 }
939 }
940
941 pub fn remove(&self, provider: &str) {
943 self.credentials.write().remove(provider);
944 if let Err(e) = self.persist() {
945 tracing::warn!("Failed to persist after removing '{}': {}", provider, e);
946 }
947 }
948
949 pub fn list_providers(&self) -> Vec<String> {
951 self.credentials.read().keys().cloned().collect()
952 }
953
954 pub fn has(&self, provider: &str) -> bool {
956 self.credentials.read().contains_key(provider)
957 }
958
959 pub fn get_all(&self) -> HashMap<String, AuthCredential> {
961 self.credentials.read().clone()
962 }
963 pub fn clear(&self) {
965 self.credentials.write().clear();
966 if let Err(e) = self.persist() {
967 tracing::warn!("Failed to persist after clearing credentials: {}", e);
968 }
969 }
970
971 pub fn reload(&self) {
977 if let Some(ref storage) = self.file_storage {
978 match storage.read() {
979 Ok(Some(content)) => {
980 if let Ok(creds) = serde_json::from_str(&content) {
981 *self.credentials.write() = creds;
982 }
983 *self.load_error.write() = None;
984 }
985 Ok(None) => {
986 self.credentials.write().clear();
987 *self.load_error.write() = None;
988 }
989 Err(e) => {
990 *self.load_error.write() = Some(e);
991 self.record_error(AuthError::ReadError(
992 "Failed to reload auth storage".to_string(),
993 ));
994 }
995 }
996 }
997 }
998
999 #[allow(unexpected_cfgs)]
1001 fn persist(&self) -> Result<(), String> {
1002 if let Some(ref storage) = self.file_storage {
1003 let creds = self.credentials.read();
1004 if let Ok(json) = serde_json::to_string_pretty(&*creds) {
1005 self.plaintext_warned.get_or_init(|| {
1013 tracing::warn!(
1014 "Auth credentials are stored in plaintext at \
1015 ~/.oxicode/auth.json (mode 0600). For OS-keyring \
1016 support, see the `oxicode-auth-keyring` crate or \
1017 the OXICODE_KEYRING=1 docs at docs/PORT_GUIDE.md."
1018 );
1019 });
1020
1021 if let Err(e) = storage.write(&json) {
1022 tracing::error!("Failed to persist auth storage: {}", e);
1023 self.record_error(e);
1024 return Err("persist failed".to_string());
1025 }
1026 }
1027 }
1028 Ok(())
1029 }
1030
1031 fn record_error(&self, error: AuthError) {
1037 self.errors.write().push(error);
1038 }
1039
1040 pub fn drain_errors(&self) -> Vec<AuthError> {
1042 let mut errors = self.errors.write();
1043 std::mem::take(&mut *errors)
1044 }
1045
1046 pub fn load_error(&self) -> Option<AuthError> {
1048 self.load_error.read().clone()
1049 }
1050
1051 pub fn validate_all(&self) -> Vec<(String, CredentialValidationError)> {
1057 let creds = self.credentials.read();
1058 let mut results = Vec::new();
1059 for (provider, cred) in creds.iter() {
1060 if let Err(e) = cred.validate() {
1061 results.push((provider.clone(), e));
1062 }
1063 }
1064 results
1065 }
1066
1067 pub fn validate(&self, provider: &str) -> Result<(), CredentialValidationError> {
1069 let creds = self.credentials.read();
1070 let cred = creds.get(provider).ok_or_else(|| {
1071 CredentialValidationError::EmptyField(format!(
1072 "no credential for provider '{}'",
1073 provider
1074 ))
1075 })?;
1076 cred.validate()
1077 }
1078
1079 pub fn configured_providers(&self) -> Vec<String> {
1085 let mut providers: Vec<String> = self.credentials.read().keys().cloned().collect();
1086 providers.sort();
1087 providers
1088 }
1089
1090 pub fn has_multiple_providers(&self) -> bool {
1092 self.credentials.read().len() > 1
1093 }
1094
1095 pub fn primary_provider(&self) -> Option<String> {
1097 let creds = self.credentials.read();
1098 creds.keys().next().cloned()
1099 }
1100
1101 pub fn migrate_provider(&self, from: &str, to: &str) -> AuthResult<()> {
1103 let mut creds = self.credentials.write();
1104 let cred = creds
1105 .remove(from)
1106 .ok_or_else(|| AuthError::NotFound(from.to_string()))?;
1107 creds.insert(to.to_string(), cred);
1108 drop(creds);
1109 let _ = self.persist();
1110 Ok(())
1111 }
1112}
1113
1114impl Default for AuthStorage {
1115 fn default() -> Self {
1116 Self::new()
1117 }
1118}
1119
1120impl oxicode_sdk::ports::AuthProvider for AuthStorage {
1137 fn get_api_key(
1138 &self,
1139 provider: &str,
1140 ) -> std::pin::Pin<
1141 Box<
1142 dyn std::future::Future<Output = Result<Option<String>, oxicode_sdk::SdkError>>
1143 + Send
1144 + '_,
1145 >,
1146 > {
1147 let result = self.get_api_key(provider);
1148 Box::pin(async move { Ok(result) })
1149 }
1150
1151 fn get_api_key_sync(&self, provider: &str) -> Result<Option<String>, oxicode_sdk::SdkError> {
1156 Ok(self.get_api_key(provider))
1157 }
1158
1159 fn set_api_key(
1160 &self,
1161 provider: &str,
1162 key: &str,
1163 ) -> std::pin::Pin<
1164 Box<dyn std::future::Future<Output = Result<(), oxicode_sdk::SdkError>> + Send + '_>,
1165 > {
1166 AuthStorage::set_api_key(self, provider, key.to_string());
1167 Box::pin(async { Ok(()) })
1168 }
1169
1170 fn delete_api_key(
1171 &self,
1172 provider: &str,
1173 ) -> std::pin::Pin<
1174 Box<dyn std::future::Future<Output = Result<(), oxicode_sdk::SdkError>> + Send + '_>,
1175 > {
1176 self.credentials.write().remove(provider);
1177 let _ = self.persist();
1178 Box::pin(async { Ok(()) })
1179 }
1180
1181 fn get_oauth(
1182 &self,
1183 provider: &str,
1184 ) -> std::pin::Pin<
1185 Box<
1186 dyn std::future::Future<
1187 Output = Result<Option<oxicode_sdk::ports::OAuthToken>, oxicode_sdk::SdkError>,
1188 > + Send
1189 + '_,
1190 >,
1191 > {
1192 let result = self
1193 .get_oauth_credential(provider)
1194 .and_then(|cred| match cred {
1195 AuthCredential::OAuth {
1196 access_token,
1197 refresh_token,
1198 expires_at,
1199 ..
1200 } => Some(oxicode_sdk::ports::OAuthToken {
1201 access_token,
1202 refresh_token,
1203 expires_at: chrono::DateTime::from_timestamp(expires_at as i64, 0),
1204 token_type: Some("Bearer".to_string()),
1205 scope: None,
1206 }),
1207 _ => None,
1208 });
1209 Box::pin(async move { Ok(result) })
1210 }
1211
1212 fn set_oauth(
1213 &self,
1214 provider: &str,
1215 token: oxicode_sdk::ports::OAuthToken,
1216 ) -> std::pin::Pin<
1217 Box<dyn std::future::Future<Output = Result<(), oxicode_sdk::SdkError>> + Send + '_>,
1218 > {
1219 let expires_at = token
1220 .expires_at
1221 .and_then(|dt| dt.timestamp().max(0).try_into().ok())
1222 .unwrap_or(0);
1223 AuthStorage::set_oauth(
1224 self,
1225 provider,
1226 token.access_token,
1227 token.refresh_token,
1228 expires_at,
1229 );
1230 Box::pin(async { Ok(()) })
1231 }
1232
1233 fn list_providers(
1234 &self,
1235 ) -> std::pin::Pin<
1236 Box<
1237 dyn std::future::Future<Output = Result<Vec<String>, oxicode_sdk::SdkError>>
1238 + Send
1239 + '_,
1240 >,
1241 > {
1242 let result = AuthStorage::list_providers(self);
1243 Box::pin(async move { Ok(result) })
1244 }
1245}
1246
1247fn now_secs() -> u64 {
1252 std::time::SystemTime::now()
1253 .duration_since(std::time::UNIX_EPOCH)
1254 .map(|d| d.as_secs())
1255 .unwrap_or(0)
1256}
1257
1258#[allow(unexpected_cfgs)]
1273#[deprecated(note = "keyring cargo feature is not wired in Cargo.toml; \
1274 this module is currently a no-op fallback. See docs/PORT_GUIDE.md \
1275 and the F-4 audit note in oxicode-cli/src/store/auth_storage.rs.")]
1276pub mod keyring_support {
1277 use super::*;
1278
1279 #[cfg(feature = "keyring")]
1281 pub fn get_keyring_secret(service: &str, account: &str) -> Option<String> {
1282 use keyring::Entry;
1283 Entry::new(service, account)
1284 .ok()
1285 .and_then(|entry| entry.get_password().ok())
1286 }
1287
1288 #[cfg(feature = "keyring")]
1290 pub fn set_keyring_secret(service: &str, account: &str, secret: &str) -> AuthResult<()> {
1291 use keyring::Entry;
1292 Entry::new(service, account)
1293 .map_err(|e| AuthError::KeyringError(e.to_string()))?
1294 .set_password(secret)
1295 .map_err(|e| AuthError::KeyringError(e.to_string()))
1296 }
1297
1298 #[cfg(feature = "keyring")]
1300 pub fn delete_keyring_secret(service: &str, account: &str) -> AuthResult<()> {
1301 use keyring::Entry;
1302 Entry::new(service, account)
1303 .map_err(|e| AuthError::KeyringError(e.to_string()))?
1304 .delete_credential()
1305 .map_err(|e| AuthError::KeyringError(e.to_string()))
1306 }
1307
1308 #[cfg(not(feature = "keyring"))]
1310 pub fn get_keyring_secret(_service: &str, _account: &str) -> Option<String> {
1314 None
1315 }
1316
1317 #[cfg(not(feature = "keyring"))]
1318 pub fn set_keyring_secret(_service: &str, _account: &str, _secret: &str) -> AuthResult<()> {
1322 Err(AuthError::KeyringError(
1323 "Keyring support not compiled".to_string(),
1324 ))
1325 }
1326
1327 #[cfg(not(feature = "keyring"))]
1328 pub fn delete_keyring_secret(_service: &str, _account: &str) -> AuthResult<()> {
1332 Err(AuthError::KeyringError(
1333 "Keyring support not compiled".to_string(),
1334 ))
1335 }
1336}
1337
1338pub fn shared_auth_storage() -> Arc<AuthStorage> {
1348 static STORAGE: OnceLock<Arc<AuthStorage>> = OnceLock::new();
1349 STORAGE
1350 .get_or_init(|| {
1351 let storage = Arc::new(AuthStorage::new());
1352 storage.set_fallback_resolver(Arc::new(EnvVarFallbackResolver));
1355 storage
1356 })
1357 .clone()
1358}
1359
1360#[cfg(test)]
1365mod tests {
1366 use super::*;
1367
1368 #[test]
1369 fn test_auth_storage_new() {
1370 let storage = AuthStorage::in_memory();
1371 assert!(!storage.has("anthropic"));
1372 }
1373
1374 #[test]
1375 fn test_set_and_get_api_key() {
1376 let storage = AuthStorage::in_memory();
1377 storage.set_api_key("anthropic", "sk-test123".to_string());
1378 assert!(storage.has("anthropic"));
1379 assert_eq!(
1380 storage.get_api_key("anthropic"),
1381 Some("sk-test123".to_string())
1382 );
1383 }
1384
1385 #[test]
1387 fn file_backend_reads_canonical_first() {
1388 let tmp = tempfile::tempdir().unwrap();
1389 let canonical = tmp.path().join("auth.json");
1390 let legacy = tmp.path().join("legacy-auth.json");
1391 std::fs::write(&canonical, r#"{"canonical":true}"#).unwrap();
1392 std::fs::write(&legacy, r#"{"legacy":true}"#).unwrap();
1393
1394 let backend = FileAuthStorage::with_legacy_fallback(canonical, Some(legacy));
1395 let content = backend.read().unwrap().unwrap();
1396 assert!(content.contains("canonical"));
1397 assert!(!content.contains("legacy"));
1398 }
1399
1400 #[test]
1403 fn file_backend_falls_back_to_legacy_read_and_writes_canonical() {
1404 let tmp = tempfile::tempdir().unwrap();
1405 let canonical = tmp.path().join("auth.json");
1406 let legacy = tmp.path().join("legacy-auth.json");
1407 std::fs::write(&legacy, r#"{"legacy":true}"#).unwrap();
1408
1409 let backend = FileAuthStorage::with_legacy_fallback(canonical.clone(), Some(legacy));
1410 let content = backend.read().unwrap().unwrap();
1411 assert!(content.contains("legacy"));
1412
1413 backend.write(r#"{"fresh":1}"#).unwrap();
1415 assert_eq!(
1416 std::fs::read_to_string(&canonical).unwrap(),
1417 r#"{"fresh":1}"#
1418 );
1419 assert_eq!(
1420 std::fs::read_to_string(backend.legacy_read_path.as_ref().unwrap()).unwrap(),
1421 r#"{"legacy":true}"#,
1422 "legacy file must remain untouched"
1423 );
1424
1425 let content = backend.read().unwrap().unwrap();
1427 assert!(content.contains("fresh"));
1428 }
1429
1430 #[test]
1432 fn file_backend_read_none_when_both_missing() {
1433 let tmp = tempfile::tempdir().unwrap();
1434 let canonical = tmp.path().join("auth.json");
1435 let backend = FileAuthStorage::with_legacy_fallback(canonical, None);
1436 assert_eq!(backend.read().unwrap(), None);
1437 }
1438
1439 #[test]
1440 fn test_runtime_override() {
1441 let storage = AuthStorage::in_memory();
1442 storage.set_api_key("anthropic", "stored-key".to_string());
1443 storage.set_runtime_key("anthropic", "runtime-key".to_string());
1444
1445 assert_eq!(
1447 storage.get_api_key("anthropic"),
1448 Some("runtime-key".to_string())
1449 );
1450 }
1451
1452 #[test]
1453 fn test_remove_credential() {
1454 let storage = AuthStorage::in_memory();
1455 storage.set_api_key("anthropic", "sk-test123".to_string());
1456 assert!(storage.has("anthropic"));
1457
1458 storage.remove("anthropic");
1459 assert!(!storage.has("anthropic"));
1460 }
1461
1462 #[test]
1463 fn test_auth_status() {
1464 let storage = AuthStorage::in_memory();
1465 storage.set_api_key("anthropic", "sk-test123".to_string());
1466
1467 let status = storage.get_status("anthropic");
1468 assert!(status.configured);
1469 assert_eq!(status.source, Some("stored".to_string()));
1470 assert_eq!(status.label, Some("api_key".to_string()));
1471 }
1472
1473 #[test]
1474 fn test_auth_status_display() {
1475 let status = AuthStatus {
1476 configured: true,
1477 source: Some("stored".to_string()),
1478 label: Some("api_key".to_string()),
1479 };
1480 let display = format!("{}", status);
1481 assert_eq!(display, "stored (api_key)");
1482
1483 let no_config = AuthStatus {
1484 configured: false,
1485 source: None,
1486 label: None,
1487 };
1488 assert_eq!(format!("{}", no_config), "not configured");
1489 }
1490
1491 #[test]
1492 fn test_list_providers() {
1493 let storage = AuthStorage::in_memory();
1494 storage.set_api_key("anthropic", "key1".to_string());
1495 storage.set_api_key("openai", "key2".to_string());
1496
1497 let providers = storage.list_providers();
1498 assert!(providers.contains(&"anthropic".to_string()));
1499 assert!(providers.contains(&"openai".to_string()));
1500 }
1501
1502 #[test]
1503 fn test_oauth_credential() {
1504 let storage = AuthStorage::in_memory();
1505 storage.set_oauth(
1506 "provider",
1507 "access123".to_string(),
1508 Some("refresh456".to_string()),
1509 u64::MAX,
1510 );
1511
1512 assert!(storage.has("provider"));
1513 assert_eq!(
1514 storage.get_api_key("provider"),
1515 Some("access123".to_string())
1516 );
1517 }
1518
1519 #[test]
1520 fn test_expired_oauth_token() {
1521 let storage = AuthStorage::in_memory();
1522 storage.set_oauth("provider", "access123".to_string(), None, 0);
1524
1525 let key = storage.get_api_key("provider");
1527 assert!(key.is_none());
1528 }
1529
1530 #[test]
1531 fn test_get_all_credentials() {
1532 let storage = AuthStorage::in_memory();
1533 storage.set_api_key("anthropic", "key1".to_string());
1534 storage.set_api_key("openai", "key2".to_string());
1535
1536 let all = storage.get_all();
1537 assert_eq!(all.len(), 2);
1538 }
1539
1540 #[test]
1541 fn test_clear() {
1542 let storage = AuthStorage::in_memory();
1543 storage.set_api_key("anthropic", "key".to_string());
1544 assert!(storage.has("anthropic"));
1545
1546 storage.clear();
1547 assert!(!storage.has("anthropic"));
1548 }
1549
1550 #[test]
1551 fn test_remove_runtime_key() {
1552 let storage = AuthStorage::in_memory();
1553 storage.set_api_key("anthropic", "stored".to_string());
1554 storage.set_runtime_key("anthropic", "runtime".to_string());
1555
1556 assert_eq!(
1557 storage.get_api_key("anthropic"),
1558 Some("runtime".to_string())
1559 );
1560
1561 storage.remove_runtime_key("anthropic");
1562 assert_eq!(storage.get_api_key("anthropic"), Some("stored".to_string()));
1563 }
1564
1565 #[test]
1566 fn test_auth_credential_is_expired() {
1567 let api_key_cred = AuthCredential::ApiKey {
1569 key: "test".to_string(),
1570 };
1571 assert!(!api_key_cred.is_expired());
1572
1573 let future_time = now_secs() + 3600;
1575 let oauth_cred = AuthCredential::OAuth {
1576 access_token: "token".to_string(),
1577 refresh_token: Some("refresh".to_string()),
1578 expires_at: future_time,
1579 scopes: None,
1580 provider_data: None,
1581 };
1582 assert!(!oauth_cred.is_expired());
1583
1584 let oauth_cred_expired = AuthCredential::OAuth {
1586 access_token: "token".to_string(),
1587 refresh_token: Some("refresh".to_string()),
1588 expires_at: 0,
1589 scopes: None,
1590 provider_data: None,
1591 };
1592 assert!(oauth_cred_expired.is_expired());
1593 }
1594
1595 #[test]
1596 fn test_auth_credential_needs_refresh() {
1597 let future_time = now_secs() + 120; let oauth_cred = AuthCredential::OAuth {
1601 access_token: "token".to_string(),
1602 refresh_token: Some("refresh".to_string()),
1603 expires_at: future_time,
1604 scopes: None,
1605 provider_data: None,
1606 };
1607 assert!(!oauth_cred.needs_refresh());
1608
1609 let soon = now_secs() + 30;
1611 let oauth_soon = AuthCredential::OAuth {
1612 access_token: "token".to_string(),
1613 refresh_token: Some("refresh".to_string()),
1614 expires_at: soon,
1615 scopes: None,
1616 provider_data: None,
1617 };
1618 assert!(oauth_soon.needs_refresh());
1619
1620 let no_refresh = AuthCredential::OAuth {
1622 access_token: "token".to_string(),
1623 refresh_token: None,
1624 expires_at: future_time,
1625 scopes: None,
1626 provider_data: None,
1627 };
1628 assert!(!no_refresh.needs_refresh());
1629
1630 let api_key_cred = AuthCredential::ApiKey {
1632 key: "test".to_string(),
1633 };
1634 assert!(!api_key_cred.needs_refresh());
1635 }
1636
1637 #[test]
1638 fn test_auth_credential_access_token() {
1639 let future_time = now_secs() + 3600;
1640
1641 let oauth_cred = AuthCredential::OAuth {
1642 access_token: "valid_token".to_string(),
1643 refresh_token: Some("refresh".to_string()),
1644 expires_at: future_time,
1645 scopes: None,
1646 provider_data: None,
1647 };
1648 assert_eq!(oauth_cred.access_token(), Some("valid_token"));
1649
1650 let expired_cred = AuthCredential::OAuth {
1652 access_token: "expired_token".to_string(),
1653 refresh_token: Some("refresh".to_string()),
1654 expires_at: 0,
1655 scopes: None,
1656 provider_data: None,
1657 };
1658 assert!(expired_cred.access_token().is_none());
1659
1660 let api_key_cred = AuthCredential::ApiKey {
1662 key: "api_key_token".to_string(),
1663 };
1664 assert!(api_key_cred.access_token().is_none());
1665 }
1666
1667 #[test]
1668 fn test_get_oauth_credential() {
1669 let storage = AuthStorage::in_memory();
1670 storage.set_oauth(
1671 "provider",
1672 "access".to_string(),
1673 Some("refresh".to_string()),
1674 u64::MAX,
1675 );
1676
1677 let cred = storage.get_oauth_credential("provider");
1678 assert!(cred.is_some());
1679 assert!(matches!(cred.unwrap(), AuthCredential::OAuth { .. }));
1680 }
1681
1682 #[test]
1683 fn test_has_oauth_with_refresh() {
1684 let storage = AuthStorage::in_memory();
1685
1686 storage.set_oauth(
1688 "with_refresh",
1689 "access".to_string(),
1690 Some("refresh".to_string()),
1691 u64::MAX,
1692 );
1693 assert!(storage.has_oauth_with_refresh("with_refresh"));
1694
1695 storage.set_oauth("without_refresh", "access".to_string(), None, u64::MAX);
1697 assert!(!storage.has_oauth_with_refresh("without_refresh"));
1698
1699 storage.set_api_key("apikey_provider", "key".to_string());
1701 assert!(!storage.has_oauth_with_refresh("apikey_provider"));
1702 }
1703
1704 #[test]
1705 fn test_set_oauth_full() {
1706 let storage = AuthStorage::in_memory();
1707 storage.set_oauth_full(
1708 "provider",
1709 "access_token".to_string(),
1710 Some("refresh_token".to_string()),
1711 3600,
1712 Some("read write".to_string()),
1713 Some(serde_json::json!({"extra": "data"})),
1714 );
1715
1716 let cred = storage.get_oauth_credential("provider");
1717 assert!(cred.is_some());
1718 if let AuthCredential::OAuth {
1719 scopes,
1720 provider_data,
1721 ..
1722 } = cred.unwrap()
1723 {
1724 assert_eq!(scopes, Some("read write".to_string()));
1725 assert!(provider_data.is_some());
1726 } else {
1727 panic!("Expected OAuth credential");
1728 }
1729 }
1730
1731 #[test]
1732 fn test_session_token() {
1733 let storage = AuthStorage::in_memory();
1734 storage.set_session(
1735 "browser",
1736 "session-token-123".to_string(),
1737 0, Some(serde_json::json!({"user": "test"})),
1739 );
1740
1741 assert!(storage.has("browser"));
1742 assert_eq!(
1743 storage.get_api_key("browser"),
1744 Some("session-token-123".to_string())
1745 );
1746
1747 let cred = storage.get("browser").unwrap();
1748 assert!(matches!(cred, AuthCredential::Session { .. }));
1749 assert!(cred.access_token().is_some());
1750 }
1751
1752 #[test]
1753 fn test_session_token_expired() {
1754 let storage = AuthStorage::in_memory();
1755 storage.set_session("browser", "session-token".to_string(), 1, None);
1756
1757 assert!(storage.get_api_key("browser").is_none());
1759 }
1760
1761 #[test]
1762 fn test_credential_validation() {
1763 let valid = AuthCredential::ApiKey {
1765 key: "sk-valid".to_string(),
1766 };
1767 assert!(valid.validate().is_ok());
1768
1769 let empty = AuthCredential::ApiKey {
1771 key: "".to_string(),
1772 };
1773 assert!(empty.validate().is_err());
1774
1775 let placeholder = AuthCredential::ApiKey {
1777 key: "your-api-key-here".to_string(),
1778 };
1779 assert!(placeholder.validate().is_err());
1780
1781 let valid_oauth = AuthCredential::OAuth {
1783 access_token: "token".to_string(),
1784 refresh_token: None,
1785 expires_at: now_secs() + 3600,
1786 scopes: None,
1787 provider_data: None,
1788 };
1789 assert!(valid_oauth.validate().is_ok());
1790
1791 let invalid_oauth = AuthCredential::OAuth {
1793 access_token: "".to_string(),
1794 refresh_token: None,
1795 expires_at: 1000,
1796 scopes: None,
1797 provider_data: None,
1798 };
1799 assert!(invalid_oauth.validate().is_err());
1800 }
1801
1802 #[test]
1803 fn test_validate_all() {
1804 let storage = AuthStorage::in_memory();
1805 storage.set_api_key("valid", "sk-good".to_string());
1806 storage.set_api_key("empty", "".to_string());
1807
1808 let errors = storage.validate_all();
1809 assert_eq!(errors.len(), 1);
1810 assert_eq!(errors[0].0, "empty");
1811 }
1812
1813 #[test]
1814 fn test_update_oauth_tokens() {
1815 let storage = AuthStorage::in_memory();
1816 storage.set_oauth(
1817 "provider",
1818 "old-access".to_string(),
1819 Some("old-refresh".to_string()),
1820 now_secs() + 3600,
1821 );
1822
1823 storage
1824 .update_oauth_tokens(
1825 "provider",
1826 "new-access".to_string(),
1827 Some("new-refresh".to_string()),
1828 now_secs() + 7200,
1829 )
1830 .unwrap();
1831
1832 let key = storage.get_api_key("provider");
1833 assert_eq!(key, Some("new-access".to_string()));
1834 }
1835
1836 #[test]
1837 fn test_update_oauth_tokens_wrong_type() {
1838 let storage = AuthStorage::in_memory();
1839 storage.set_api_key("provider", "key".to_string());
1840
1841 let result = storage.update_oauth_tokens(
1842 "provider",
1843 "new-access".to_string(),
1844 None,
1845 now_secs() + 3600,
1846 );
1847 assert!(result.is_err());
1848 }
1849
1850 #[test]
1851 fn test_migrate_provider() {
1852 let storage = AuthStorage::in_memory();
1853 storage.set_api_key("old-provider", "key123".to_string());
1854 storage
1855 .migrate_provider("old-provider", "new-provider")
1856 .unwrap();
1857
1858 assert!(!storage.has("old-provider"));
1859 assert!(storage.has("new-provider"));
1860 assert_eq!(
1861 storage.get_api_key("new-provider"),
1862 Some("key123".to_string())
1863 );
1864 }
1865
1866 #[test]
1867 fn test_migrate_provider_not_found() {
1868 let storage = AuthStorage::in_memory();
1869 let result = storage.migrate_provider("nonexistent", "target");
1870 assert!(result.is_err());
1871 }
1872
1873 #[test]
1874 fn test_error_draining() {
1875 let storage = AuthStorage::in_memory();
1876 let errors = storage.drain_errors();
1877 assert!(errors.is_empty());
1878 }
1879
1880 #[test]
1881 fn test_fallback_resolver() {
1882 let storage = AuthStorage::in_memory();
1883 storage.set_fallback_resolver(Arc::new(FnFallbackResolver::new(Box::new(|provider| {
1884 if provider == "custom" {
1885 Some("custom-key-from-config".to_string())
1886 } else {
1887 None
1888 }
1889 }))));
1890
1891 assert_eq!(
1892 storage.get_api_key("custom"),
1893 Some("custom-key-from-config".to_string())
1894 );
1895 assert!(storage.get_api_key("unknown").is_none());
1896
1897 storage.clear_fallback_resolver();
1899 assert!(storage.get_api_key("custom").is_none());
1900 }
1901
1902 #[test]
1903 fn test_get_api_key_with_options() {
1904 let storage = AuthStorage::in_memory();
1905 storage.set_fallback_resolver(Arc::new(FnFallbackResolver::new(Box::new(|_| {
1906 Some("fallback-key".to_string())
1907 }))));
1908
1909 assert_eq!(
1911 storage.get_api_key_with_options("test", true),
1912 Some("fallback-key".to_string())
1913 );
1914
1915 assert!(storage.get_api_key_with_options("test", false).is_none());
1917 }
1918
1919 #[test]
1920 fn test_configured_providers() {
1921 let storage = AuthStorage::in_memory();
1922 storage.set_api_key("openai", "key".to_string());
1923 storage.set_api_key("anthropic", "key".to_string());
1924
1925 let providers = storage.configured_providers();
1926 assert!(providers.len() >= 2);
1927 let mut sorted = providers.clone();
1929 sorted.sort();
1930 assert_eq!(providers, sorted);
1931 }
1932
1933 #[test]
1934 fn test_has_multiple_providers() {
1935 let storage = AuthStorage::in_memory();
1936 assert!(!storage.has_multiple_providers());
1937
1938 storage.set_api_key("openai", "key1".to_string());
1939 assert!(!storage.has_multiple_providers());
1940
1941 storage.set_api_key("anthropic", "key2".to_string());
1942 assert!(storage.has_multiple_providers());
1943 }
1944
1945 #[test]
1946 fn test_set_and_get_credential() {
1947 let storage = AuthStorage::in_memory();
1948 let cred = AuthCredential::Session {
1949 token: "abc".to_string(),
1950 expires_at: 0,
1951 metadata: None,
1952 };
1953 storage.set("custom", cred);
1954 let retrieved = storage.get("custom");
1955 assert!(retrieved.is_some());
1956 assert!(matches!(retrieved.unwrap(), AuthCredential::Session { .. }));
1957 }
1958
1959 #[test]
1960 fn test_credential_type_name() {
1961 assert_eq!(
1962 AuthCredential::ApiKey {
1963 key: "k".to_string()
1964 }
1965 .type_name(),
1966 "api_key"
1967 );
1968 assert_eq!(
1969 AuthCredential::OAuth {
1970 access_token: "t".to_string(),
1971 refresh_token: None,
1972 expires_at: 0,
1973 scopes: None,
1974 provider_data: None,
1975 }
1976 .type_name(),
1977 "oauth"
1978 );
1979 assert_eq!(
1980 AuthCredential::Session {
1981 token: "t".to_string(),
1982 expires_at: 0,
1983 metadata: None,
1984 }
1985 .type_name(),
1986 "session"
1987 );
1988 }
1989
1990 #[test]
1993 fn debug_impl_masks_api_key() {
1994 let cred = AuthCredential::ApiKey {
1995 key: "sk-super-secret-12345".to_string(),
1996 };
1997 let dbg = format!("{cred:?}");
1998 assert!(
1999 !dbg.contains("sk-super-secret-12345"),
2000 "Debug must not leak the API key, got: {dbg}"
2001 );
2002 assert!(
2003 dbg.contains("<redacted>"),
2004 "Debug must show <redacted> placeholder, got: {dbg}"
2005 );
2006 }
2007
2008 #[test]
2009 fn debug_impl_masks_oauth_tokens() {
2010 let cred = AuthCredential::OAuth {
2011 access_token: "ya29.access-token-value".to_string(),
2012 refresh_token: Some("ya29.refresh-token-value".to_string()),
2013 expires_at: 9999999999,
2014 scopes: None,
2015 provider_data: None,
2016 };
2017 let dbg = format!("{cred:?}");
2018 assert!(
2019 !dbg.contains("access-token-value"),
2020 "Debug must not leak the access token, got: {dbg}"
2021 );
2022 assert!(
2023 !dbg.contains("refresh-token-value"),
2024 "Debug must not leak the refresh token, got: {dbg}"
2025 );
2026 assert!(
2028 dbg.contains("9999999999"),
2029 "Debug must still show expires_at for diagnostics, got: {dbg}"
2030 );
2031 }
2032
2033 #[test]
2034 fn debug_impl_masks_session_token() {
2035 let cred = AuthCredential::Session {
2036 token: "sess-abc-def-ghi".to_string(),
2037 expires_at: 0,
2038 metadata: None,
2039 };
2040 let dbg = format!("{cred:?}");
2041 assert!(
2042 !dbg.contains("sess-abc-def-ghi"),
2043 "Debug must not leak the session token, got: {dbg}"
2044 );
2045 assert!(
2046 dbg.contains("<redacted>"),
2047 "Debug must show <redacted> placeholder, got: {dbg}"
2048 );
2049 }
2050}