1use parking_lot::RwLock;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::PathBuf;
11use std::sync::{Arc, OnceLock};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(tag = "type", rename_all = "snake_case")]
20pub enum AuthCredential {
21 ApiKey {
23 key: String,
25 },
26 OAuth {
28 access_token: String,
30 refresh_token: Option<String>,
32 expires_at: u64,
34 #[serde(default)]
36 scopes: Option<String>,
37 #[serde(default)]
39 provider_data: Option<serde_json::Value>,
40 },
41 Session {
43 token: String,
45 #[serde(default)]
47 expires_at: u64,
48 #[serde(default)]
50 metadata: Option<serde_json::Value>,
51 },
52}
53
54impl AuthCredential {
55 pub fn is_expired(&self) -> bool {
57 match self {
58 AuthCredential::OAuth { expires_at, .. } => {
59 let now = now_secs();
60 *expires_at < now
61 }
62 AuthCredential::Session { expires_at, .. } => {
63 if *expires_at == 0 {
64 return false; }
66 *expires_at <= now_secs()
67 }
68 AuthCredential::ApiKey { .. } => false,
69 }
70 }
71
72 pub fn needs_refresh(&self) -> bool {
74 match self {
75 AuthCredential::OAuth {
76 expires_at,
77 refresh_token,
78 ..
79 } => {
80 let now = now_secs();
81 refresh_token.is_some() && *expires_at <= now + 60
82 }
83 AuthCredential::Session { .. } => false,
84 AuthCredential::ApiKey { .. } => false,
85 }
86 }
87
88 pub fn access_token(&self) -> Option<&str> {
90 match self {
91 AuthCredential::OAuth { access_token, .. } if !self.is_expired() => Some(access_token),
92 AuthCredential::Session { token, .. } if !self.is_expired() => Some(token),
93 _ => None,
94 }
95 }
96
97 pub fn type_name(&self) -> &'static str {
99 match self {
100 AuthCredential::ApiKey { .. } => "api_key",
101 AuthCredential::OAuth { .. } => "oauth",
102 AuthCredential::Session { .. } => "session",
103 }
104 }
105
106 pub fn validate(&self) -> Result<(), CredentialValidationError> {
108 match self {
109 AuthCredential::ApiKey { key } => {
110 if key.is_empty() {
111 return Err(CredentialValidationError::EmptyField("key".to_string()));
112 }
113 if key == "your-api-key-here" || key == "xxx" {
115 return Err(CredentialValidationError::PlaceholderValue(key.clone()));
116 }
117 Ok(())
118 }
119 AuthCredential::OAuth {
120 access_token,
121 expires_at,
122 ..
123 } => {
124 if access_token.is_empty() {
125 return Err(CredentialValidationError::EmptyField(
126 "access_token".to_string(),
127 ));
128 }
129 if *expires_at == 0 {
130 return Err(CredentialValidationError::InvalidExpiry);
131 }
132 Ok(())
133 }
134 AuthCredential::Session { token, .. } => {
135 if token.is_empty() {
136 return Err(CredentialValidationError::EmptyField("token".to_string()));
137 }
138 Ok(())
139 }
140 }
141 }
142}
143
144#[derive(Debug, Clone, thiserror::Error)]
146pub enum CredentialValidationError {
147 #[error("Field '{0}' must not be empty")]
148 EmptyField(String),
150 #[error("Placeholder value detected: '{0}'")]
151 PlaceholderValue(String),
153 #[error("Invalid expiry timestamp")]
154 InvalidExpiry,
156}
157
158#[derive(Debug, Clone)]
164pub struct AuthStatus {
165 pub configured: bool,
167 pub source: Option<String>,
169 pub label: Option<String>,
171}
172
173impl std::fmt::Display for AuthStatus {
174 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175 match (&self.source, &self.label) {
176 (Some(source), Some(label)) => write!(f, "{} ({})", source, label),
177 (Some(source), None) => write!(f, "{}", source),
178 (None, Some(label)) => write!(f, "{}", label),
179 (None, None) => write!(f, "not configured"),
180 }
181 }
182}
183
184pub type AuthResult<T> = Result<T, AuthError>;
190
191#[derive(Debug, Clone, thiserror::Error)]
193pub enum AuthError {
194 #[error("Failed to read auth storage: {0}")]
195 ReadError(String),
197 #[error("Failed to write auth storage: {0}")]
198 WriteError(String),
200 #[error("Credential not found: {0}")]
201 NotFound(String),
203 #[error("Invalid credential format: {0}")]
204 InvalidFormat(String),
206 #[error("Keyring error: {0}")]
207 KeyringError(String),
209 #[error("Credential validation failed: {0}")]
210 ValidationFailed(String),
212}
213
214pub trait AuthStorageBackend: Send + Sync {
220 fn read(&self) -> AuthResult<Option<String>>;
222 fn write(&self, data: &str) -> AuthResult<()>;
224 fn delete(&self) -> AuthResult<()>;
226}
227
228pub struct FileAuthStorage {
234 path: PathBuf,
235 cache: RwLock<Option<String>>,
236}
237
238impl FileAuthStorage {
239 pub fn new(path: PathBuf) -> Self {
241 Self {
242 path,
243 cache: RwLock::new(None),
244 }
245 }
246
247 pub fn default_path() -> Option<PathBuf> {
249 dirs::home_dir().map(|p| p.join(".oxi").join("auth.json"))
250 }
251
252 pub fn path(&self) -> &PathBuf {
254 &self.path
255 }
256}
257
258impl AuthStorageBackend for FileAuthStorage {
259 fn read(&self) -> AuthResult<Option<String>> {
260 if !self.path.exists() {
261 return Ok(None);
262 }
263
264 match std::fs::read_to_string(&self.path) {
265 Ok(content) => {
266 *self.cache.write() = Some(content.clone());
267 Ok(Some(content))
268 }
269 Err(e) => Err(AuthError::ReadError(e.to_string())),
270 }
271 }
272
273 fn write(&self, data: &str) -> AuthResult<()> {
274 if let Some(parent) = self.path.parent() {
276 std::fs::create_dir_all(parent).map_err(|e| AuthError::WriteError(e.to_string()))?;
277
278 #[cfg(unix)]
279 {
280 use std::os::unix::fs::PermissionsExt;
281 let perms = std::fs::Permissions::from_mode(0o700);
282 let _ = std::fs::set_permissions(parent, perms);
283 }
284 }
285
286 std::fs::write(&self.path, data).map_err(|e| AuthError::WriteError(e.to_string()))?;
288
289 #[cfg(unix)]
291 {
292 use std::os::unix::fs::PermissionsExt;
293 let perms = std::fs::Permissions::from_mode(0o600);
294 std::fs::set_permissions(&self.path, perms)
295 .map_err(|e| AuthError::WriteError(e.to_string()))?;
296 }
297
298 *self.cache.write() = Some(data.to_string());
299 Ok(())
300 }
301
302 fn delete(&self) -> AuthResult<()> {
303 if self.path.exists() {
304 std::fs::remove_file(&self.path).map_err(|e| AuthError::WriteError(e.to_string()))?;
305 }
306 *self.cache.write() = None;
307 Ok(())
308 }
309}
310
311pub struct MemoryAuthStorage {
317 data: RwLock<HashMap<String, AuthCredential>>,
318}
319
320impl MemoryAuthStorage {
321 pub fn new() -> Self {
323 Self {
324 data: RwLock::new(HashMap::new()),
325 }
326 }
327}
328
329impl Default for MemoryAuthStorage {
330 fn default() -> Self {
331 Self::new()
332 }
333}
334
335impl AuthStorageBackend for MemoryAuthStorage {
336 fn read(&self) -> AuthResult<Option<String>> {
337 Ok(None)
339 }
340
341 fn write(&self, _data: &str) -> AuthResult<()> {
342 Ok(())
343 }
344
345 fn delete(&self) -> AuthResult<()> {
346 self.data.write().clear();
347 Ok(())
348 }
349}
350
351pub trait FallbackResolver: Send + Sync {
357 fn resolve(&self, provider: &str) -> Option<String>;
359}
360
361pub struct FnFallbackResolver {
363 #[allow(clippy::type_complexity)]
364 f: Box<dyn Fn(&str) -> Option<String> + Send + Sync>,
365}
366
367impl FnFallbackResolver {
368 #[allow(clippy::type_complexity)]
370 pub fn new(f: Box<dyn Fn(&str) -> Option<String> + Send + Sync>) -> Self {
371 Self { f }
372 }
373}
374
375impl FallbackResolver for FnFallbackResolver {
376 fn resolve(&self, provider: &str) -> Option<String> {
377 (self.f)(provider)
378 }
379}
380
381pub struct EnvVarFallbackResolver;
386
387impl FallbackResolver for EnvVarFallbackResolver {
388 fn resolve(&self, provider: &str) -> Option<String> {
389 let builtin = oxi_ai::get_builtin_provider(provider)?;
391 let key = builtin.env_key;
392
393 if let Ok(val) = std::env::var(key)
395 && !val.is_empty()
396 {
397 return Some(val);
398 }
399
400 for extra in builtin.extra_env_keys {
402 if let Ok(val) = std::env::var(extra)
403 && !val.is_empty()
404 {
405 return Some(val);
406 }
407 }
408
409 None
410 }
411}
412
413pub struct AuthStorage {
427 file_storage: Option<Arc<dyn AuthStorageBackend>>,
429 credentials: RwLock<HashMap<String, AuthCredential>>,
431 runtime_overrides: RwLock<HashMap<String, String>>,
433 fallback_resolver: RwLock<Option<Arc<dyn FallbackResolver>>>,
435 errors: RwLock<Vec<AuthError>>,
437 load_error: RwLock<Option<AuthError>>,
439 plaintext_warned: OnceLock<()>,
441}
442
443impl AuthStorage {
444 pub fn new() -> Self {
446 let file_storage = FileAuthStorage::default_path()
447 .map(|p| Arc::new(FileAuthStorage::new(p)) as Arc<dyn AuthStorageBackend>);
448
449 let credentials = if let Some(ref storage) = file_storage {
450 match storage.read() {
451 Ok(Some(content)) => serde_json::from_str(&content).unwrap_or_default(),
452 _ => HashMap::new(),
453 }
454 } else {
455 HashMap::new()
456 };
457
458 Self {
459 file_storage,
460 credentials: RwLock::new(credentials),
461 runtime_overrides: RwLock::new(HashMap::new()),
462 fallback_resolver: RwLock::new(None),
463 errors: RwLock::new(Vec::new()),
464 load_error: RwLock::new(None),
465 plaintext_warned: OnceLock::new(),
466 }
467 }
468
469 pub fn with_backend(backend: impl AuthStorageBackend + 'static) -> Self {
471 let credentials = match backend.read() {
472 Ok(Some(content)) => serde_json::from_str(&content).unwrap_or_default(),
473 _ => HashMap::new(),
474 };
475
476 Self {
477 file_storage: Some(Arc::new(backend)),
478 credentials: RwLock::new(credentials),
479 runtime_overrides: RwLock::new(HashMap::new()),
480 fallback_resolver: RwLock::new(None),
481 errors: RwLock::new(Vec::new()),
482 load_error: RwLock::new(None),
483 plaintext_warned: OnceLock::new(),
484 }
485 }
486
487 pub fn in_memory() -> Self {
489 Self {
490 file_storage: None,
491 credentials: RwLock::new(HashMap::new()),
492 runtime_overrides: RwLock::new(HashMap::new()),
493 fallback_resolver: RwLock::new(None),
494 errors: RwLock::new(Vec::new()),
495 load_error: RwLock::new(None),
496 plaintext_warned: OnceLock::new(),
497 }
498 }
499
500 pub fn default_path() -> Option<PathBuf> {
502 FileAuthStorage::default_path()
503 }
504
505 pub fn set_runtime_key(&self, provider: &str, api_key: String) {
511 self.runtime_overrides
512 .write()
513 .insert(provider.to_string(), api_key);
514 }
515
516 pub fn remove_runtime_key(&self, provider: &str) {
518 self.runtime_overrides.write().remove(provider);
519 }
520
521 pub fn set_fallback_resolver(&self, resolver: Arc<dyn FallbackResolver>) {
528 *self.fallback_resolver.write() = Some(resolver);
529 }
530
531 pub fn clear_fallback_resolver(&self) {
533 *self.fallback_resolver.write() = None;
534 }
535
536 pub fn has_auth(&self, provider: &str) -> bool {
542 if self.runtime_overrides.read().contains_key(provider) {
543 return true;
544 }
545 if self.credentials.read().contains_key(provider) {
546 return true;
547 }
548 if let Some(ref resolver) = *self.fallback_resolver.read()
549 && resolver.resolve(provider).is_some()
550 {
551 return true;
552 }
553 false
554 }
555
556 pub fn get_status(&self, provider: &str) -> AuthStatus {
558 if self.runtime_overrides.read().contains_key(provider) {
559 return AuthStatus {
560 configured: false,
561 source: Some("runtime".to_string()),
562 label: Some("--api-key".to_string()),
563 };
564 }
565
566 if let Some(cred) = self.credentials.read().get(provider) {
567 return AuthStatus {
568 configured: true,
569 source: Some("stored".to_string()),
570 label: Some(cred.type_name().to_string()),
571 };
572 }
573
574 if let Some(ref resolver) = *self.fallback_resolver.read()
575 && resolver.resolve(provider).is_some()
576 {
577 return AuthStatus {
578 configured: false,
579 source: Some("fallback".to_string()),
580 label: Some("custom provider config".to_string()),
581 };
582 }
583
584 AuthStatus {
585 configured: false,
586 source: None,
587 label: None,
588 }
589 }
590
591 pub fn get_api_key(&self, provider: &str) -> Option<String> {
600 self.get_api_key_with_options(provider, true)
601 }
602
603 pub fn get_api_key_with_options(
605 &self,
606 provider: &str,
607 include_fallback: bool,
608 ) -> Option<String> {
609 if let Some(key) = self.runtime_overrides.read().get(provider) {
611 return Some(key.clone());
612 }
613
614 if let Some(cred) = self.credentials.read().get(provider) {
616 return match cred {
617 AuthCredential::ApiKey { key } => Some(key.clone()),
618 AuthCredential::OAuth {
619 access_token,
620 expires_at,
621 ..
622 } => {
623 if *expires_at > now_secs() {
624 Some(access_token.clone())
625 } else {
626 None
628 }
629 }
630 AuthCredential::Session {
631 token, expires_at, ..
632 } => {
633 if *expires_at == 0 || *expires_at > now_secs() {
634 Some(token.clone())
635 } else {
636 None
637 }
638 }
639 };
640 }
641
642 if let Some(builtin) = oxi_ai::register_builtins::get_builtin_provider(provider) {
647 let env_key = builtin.env_key;
648 let credentials = self.credentials.read();
649 for other in oxi_ai::register_builtins::get_builtin_providers() {
650 if other.name == provider {
651 continue; }
653 if other.env_key == env_key
654 && let Some(cred) = credentials.get(other.name)
655 {
656 return match cred {
657 AuthCredential::ApiKey { key } => Some(key.clone()),
658 AuthCredential::OAuth {
659 access_token,
660 expires_at,
661 ..
662 } => {
663 if *expires_at > now_secs() {
664 Some(access_token.clone())
665 } else {
666 None
667 }
668 }
669 AuthCredential::Session {
670 token, expires_at, ..
671 } => {
672 if *expires_at == 0 || *expires_at > now_secs() {
673 Some(token.clone())
674 } else {
675 None
676 }
677 }
678 };
679 }
680 }
681 }
682
683 if include_fallback && let Some(ref resolver) = *self.fallback_resolver.read() {
685 return resolver.resolve(provider);
686 }
687
688 None
689 }
690
691 pub fn set_api_key(&self, provider: &str, key: String) {
697 self.credentials
698 .write()
699 .insert(provider.to_string(), AuthCredential::ApiKey { key });
700 if let Err(e) = self.persist() {
701 tracing::warn!("Failed to persist API key for '{}': {}", provider, e);
702 }
703 }
704
705 pub fn set_oauth(
707 &self,
708 provider: &str,
709 access_token: String,
710 refresh_token: Option<String>,
711 expires_at: u64,
712 ) {
713 self.set_oauth_full(
714 provider,
715 access_token,
716 refresh_token,
717 expires_at,
718 None,
719 None,
720 );
721 }
722
723 pub fn set_oauth_full(
725 &self,
726 provider: &str,
727 access_token: String,
728 refresh_token: Option<String>,
729 expires_at: u64,
730 scopes: Option<String>,
731 provider_data: Option<serde_json::Value>,
732 ) {
733 self.credentials.write().insert(
734 provider.to_string(),
735 AuthCredential::OAuth {
736 access_token,
737 refresh_token,
738 expires_at,
739 scopes,
740 provider_data,
741 },
742 );
743 if let Err(e) = self.persist() {
744 tracing::warn!("Failed to persist OAuth token for '{}': {}", provider, e);
745 }
746 }
747
748 pub fn set_session(
750 &self,
751 provider: &str,
752 token: String,
753 expires_at: u64,
754 metadata: Option<serde_json::Value>,
755 ) {
756 self.credentials.write().insert(
757 provider.to_string(),
758 AuthCredential::Session {
759 token,
760 expires_at,
761 metadata,
762 },
763 );
764 if let Err(e) = self.persist() {
765 tracing::warn!("Failed to persist session for '{}': {}", provider, e);
766 }
767 }
768
769 pub fn update_oauth_tokens(
771 &self,
772 provider: &str,
773 new_access_token: String,
774 new_refresh_token: Option<String>,
775 new_expires_at: u64,
776 ) -> AuthResult<()> {
777 let mut creds = self.credentials.write();
778 let cred = creds
779 .get_mut(provider)
780 .ok_or_else(|| AuthError::NotFound(provider.to_string()))?;
781
782 match cred {
783 AuthCredential::OAuth {
784 access_token,
785 refresh_token,
786 expires_at,
787 ..
788 } => {
789 *access_token = new_access_token;
790 *refresh_token = new_refresh_token;
791 *expires_at = new_expires_at;
792 }
793 _ => {
794 return Err(AuthError::InvalidFormat(format!(
795 "Provider '{}' does not have OAuth credentials",
796 provider
797 )));
798 }
799 }
800
801 drop(creds);
802 if let Err(e) = self.persist() {
803 tracing::warn!(
804 "Failed to persist OAuth token update for '{}': {}",
805 provider,
806 e
807 );
808 }
809 Ok(())
810 }
811
812 pub fn get(&self, provider: &str) -> Option<AuthCredential> {
818 self.credentials.read().get(provider).cloned()
819 }
820
821 pub fn get_oauth_credential(&self, provider: &str) -> Option<AuthCredential> {
823 self.credentials.read().get(provider).cloned()
824 }
825
826 pub fn has_oauth_with_refresh(&self, provider: &str) -> bool {
828 if let Some(cred) = self.credentials.read().get(provider) {
829 matches!(
830 cred,
831 AuthCredential::OAuth {
832 refresh_token: Some(_),
833 ..
834 }
835 )
836 } else {
837 false
838 }
839 }
840
841 pub fn set(&self, provider: &str, credential: AuthCredential) {
847 self.credentials
848 .write()
849 .insert(provider.to_string(), credential);
850 if let Err(e) = self.persist() {
851 tracing::warn!("Failed to persist credential for '{}': {}", provider, e);
852 }
853 }
854
855 pub fn remove(&self, provider: &str) {
857 self.credentials.write().remove(provider);
858 if let Err(e) = self.persist() {
859 tracing::warn!("Failed to persist after removing '{}': {}", provider, e);
860 }
861 }
862
863 pub fn list_providers(&self) -> Vec<String> {
865 self.credentials.read().keys().cloned().collect()
866 }
867
868 pub fn has(&self, provider: &str) -> bool {
870 self.credentials.read().contains_key(provider)
871 }
872
873 pub fn get_all(&self) -> HashMap<String, AuthCredential> {
875 self.credentials.read().clone()
876 }
877
878 pub fn clear(&self) {
880 self.credentials.write().clear();
881 if let Err(e) = self.persist() {
882 tracing::warn!("Failed to persist after clearing credentials: {}", e);
883 }
884 }
885
886 pub fn reload(&self) {
892 if let Some(ref storage) = self.file_storage {
893 match storage.read() {
894 Ok(Some(content)) => {
895 if let Ok(creds) = serde_json::from_str(&content) {
896 *self.credentials.write() = creds;
897 }
898 *self.load_error.write() = None;
899 }
900 Ok(None) => {
901 self.credentials.write().clear();
902 *self.load_error.write() = None;
903 }
904 Err(e) => {
905 *self.load_error.write() = Some(e);
906 self.record_error(AuthError::ReadError(
907 "Failed to reload auth storage".to_string(),
908 ));
909 }
910 }
911 }
912 }
913
914 #[allow(unexpected_cfgs)]
916 fn persist(&self) -> Result<(), String> {
917 if let Some(ref storage) = self.file_storage {
918 let creds = self.credentials.read();
919 if let Ok(json) = serde_json::to_string_pretty(&*creds) {
920 self.plaintext_warned.get_or_init(|| {
928 tracing::warn!(
929 "Auth credentials are stored in plaintext at \
930 ~/.oxi/auth.json (mode 0600). For OS-keyring \
931 support, see the `oxi-auth-keyring` crate or \
932 the OXI_KEYRING=1 docs at docs/PORT_GUIDE.md."
933 );
934 });
935
936 if let Err(e) = storage.write(&json) {
937 tracing::error!("Failed to persist auth storage: {}", e);
938 self.record_error(e);
939 return Err("persist failed".to_string());
940 }
941 }
942 }
943 Ok(())
944 }
945
946 fn record_error(&self, error: AuthError) {
952 self.errors.write().push(error);
953 }
954
955 pub fn drain_errors(&self) -> Vec<AuthError> {
957 let mut errors = self.errors.write();
958 std::mem::take(&mut *errors)
959 }
960
961 pub fn load_error(&self) -> Option<AuthError> {
963 self.load_error.read().clone()
964 }
965
966 pub fn validate_all(&self) -> Vec<(String, CredentialValidationError)> {
972 let creds = self.credentials.read();
973 let mut results = Vec::new();
974 for (provider, cred) in creds.iter() {
975 if let Err(e) = cred.validate() {
976 results.push((provider.clone(), e));
977 }
978 }
979 results
980 }
981
982 pub fn validate(&self, provider: &str) -> Result<(), CredentialValidationError> {
984 let creds = self.credentials.read();
985 let cred = creds.get(provider).ok_or_else(|| {
986 CredentialValidationError::EmptyField(format!(
987 "no credential for provider '{}'",
988 provider
989 ))
990 })?;
991 cred.validate()
992 }
993
994 pub fn configured_providers(&self) -> Vec<String> {
1000 let mut providers: Vec<String> = self.credentials.read().keys().cloned().collect();
1001 providers.sort();
1002 providers
1003 }
1004
1005 pub fn has_multiple_providers(&self) -> bool {
1007 self.credentials.read().len() > 1
1008 }
1009
1010 pub fn primary_provider(&self) -> Option<String> {
1012 let creds = self.credentials.read();
1013 creds.keys().next().cloned()
1014 }
1015
1016 pub fn migrate_provider(&self, from: &str, to: &str) -> AuthResult<()> {
1018 let mut creds = self.credentials.write();
1019 let cred = creds
1020 .remove(from)
1021 .ok_or_else(|| AuthError::NotFound(from.to_string()))?;
1022 creds.insert(to.to_string(), cred);
1023 drop(creds);
1024 let _ = self.persist();
1025 Ok(())
1026 }
1027}
1028
1029impl Default for AuthStorage {
1030 fn default() -> Self {
1031 Self::new()
1032 }
1033}
1034
1035fn now_secs() -> u64 {
1040 std::time::SystemTime::now()
1041 .duration_since(std::time::UNIX_EPOCH)
1042 .map(|d| d.as_secs())
1043 .unwrap_or(0)
1044}
1045
1046#[allow(unexpected_cfgs)]
1061#[deprecated(note = "keyring cargo feature is not wired in Cargo.toml; \
1062 this module is currently a no-op fallback. See docs/PORT_GUIDE.md \
1063 and the F-4 audit note in oxi-cli/src/store/auth_storage.rs.")]
1064pub mod keyring_support {
1065 use super::*;
1066
1067 #[cfg(feature = "keyring")]
1069 pub fn get_keyring_secret(service: &str, account: &str) -> Option<String> {
1070 use keyring::Entry;
1071 Entry::new(service, account)
1072 .ok()
1073 .and_then(|entry| entry.get_password().ok())
1074 }
1075
1076 #[cfg(feature = "keyring")]
1078 pub fn set_keyring_secret(service: &str, account: &str, secret: &str) -> AuthResult<()> {
1079 use keyring::Entry;
1080 Entry::new(service, account)
1081 .map_err(|e| AuthError::KeyringError(e.to_string()))?
1082 .set_password(secret)
1083 .map_err(|e| AuthError::KeyringError(e.to_string()))
1084 }
1085
1086 #[cfg(feature = "keyring")]
1088 pub fn delete_keyring_secret(service: &str, account: &str) -> AuthResult<()> {
1089 use keyring::Entry;
1090 Entry::new(service, account)
1091 .map_err(|e| AuthError::KeyringError(e.to_string()))?
1092 .delete_credential()
1093 .map_err(|e| AuthError::KeyringError(e.to_string()))
1094 }
1095
1096 #[cfg(not(feature = "keyring"))]
1098 pub fn get_keyring_secret(_service: &str, _account: &str) -> Option<String> {
1102 None
1103 }
1104
1105 #[cfg(not(feature = "keyring"))]
1106 pub fn set_keyring_secret(_service: &str, _account: &str, _secret: &str) -> AuthResult<()> {
1110 Err(AuthError::KeyringError(
1111 "Keyring support not compiled".to_string(),
1112 ))
1113 }
1114
1115 #[cfg(not(feature = "keyring"))]
1116 pub fn delete_keyring_secret(_service: &str, _account: &str) -> AuthResult<()> {
1120 Err(AuthError::KeyringError(
1121 "Keyring support not compiled".to_string(),
1122 ))
1123 }
1124}
1125
1126pub fn shared_auth_storage() -> Arc<AuthStorage> {
1136 static STORAGE: OnceLock<Arc<AuthStorage>> = OnceLock::new();
1137 STORAGE
1138 .get_or_init(|| {
1139 let storage = Arc::new(AuthStorage::new());
1140 storage.set_fallback_resolver(Arc::new(EnvVarFallbackResolver));
1143 storage
1144 })
1145 .clone()
1146}
1147
1148#[cfg(test)]
1153mod tests {
1154 use super::*;
1155
1156 #[test]
1157 fn test_auth_storage_new() {
1158 let storage = AuthStorage::in_memory();
1159 assert!(!storage.has("anthropic"));
1160 }
1161
1162 #[test]
1163 fn test_set_and_get_api_key() {
1164 let storage = AuthStorage::in_memory();
1165 storage.set_api_key("anthropic", "sk-test123".to_string());
1166 assert!(storage.has("anthropic"));
1167 assert_eq!(
1168 storage.get_api_key("anthropic"),
1169 Some("sk-test123".to_string())
1170 );
1171 }
1172
1173 #[test]
1174 fn test_runtime_override() {
1175 let storage = AuthStorage::in_memory();
1176 storage.set_api_key("anthropic", "stored-key".to_string());
1177 storage.set_runtime_key("anthropic", "runtime-key".to_string());
1178
1179 assert_eq!(
1181 storage.get_api_key("anthropic"),
1182 Some("runtime-key".to_string())
1183 );
1184 }
1185
1186 #[test]
1187 fn test_remove_credential() {
1188 let storage = AuthStorage::in_memory();
1189 storage.set_api_key("anthropic", "sk-test123".to_string());
1190 assert!(storage.has("anthropic"));
1191
1192 storage.remove("anthropic");
1193 assert!(!storage.has("anthropic"));
1194 }
1195
1196 #[test]
1197 fn test_auth_status() {
1198 let storage = AuthStorage::in_memory();
1199 storage.set_api_key("anthropic", "sk-test123".to_string());
1200
1201 let status = storage.get_status("anthropic");
1202 assert!(status.configured);
1203 assert_eq!(status.source, Some("stored".to_string()));
1204 assert_eq!(status.label, Some("api_key".to_string()));
1205 }
1206
1207 #[test]
1208 fn test_auth_status_display() {
1209 let status = AuthStatus {
1210 configured: true,
1211 source: Some("stored".to_string()),
1212 label: Some("api_key".to_string()),
1213 };
1214 let display = format!("{}", status);
1215 assert_eq!(display, "stored (api_key)");
1216
1217 let no_config = AuthStatus {
1218 configured: false,
1219 source: None,
1220 label: None,
1221 };
1222 assert_eq!(format!("{}", no_config), "not configured");
1223 }
1224
1225 #[test]
1226 fn test_list_providers() {
1227 let storage = AuthStorage::in_memory();
1228 storage.set_api_key("anthropic", "key1".to_string());
1229 storage.set_api_key("openai", "key2".to_string());
1230
1231 let providers = storage.list_providers();
1232 assert!(providers.contains(&"anthropic".to_string()));
1233 assert!(providers.contains(&"openai".to_string()));
1234 }
1235
1236 #[test]
1237 fn test_oauth_credential() {
1238 let storage = AuthStorage::in_memory();
1239 storage.set_oauth(
1240 "provider",
1241 "access123".to_string(),
1242 Some("refresh456".to_string()),
1243 u64::MAX,
1244 );
1245
1246 assert!(storage.has("provider"));
1247 assert_eq!(
1248 storage.get_api_key("provider"),
1249 Some("access123".to_string())
1250 );
1251 }
1252
1253 #[test]
1254 fn test_expired_oauth_token() {
1255 let storage = AuthStorage::in_memory();
1256 storage.set_oauth("provider", "access123".to_string(), None, 0);
1258
1259 let key = storage.get_api_key("provider");
1261 assert!(key.is_none());
1262 }
1263
1264 #[test]
1265 fn test_get_all_credentials() {
1266 let storage = AuthStorage::in_memory();
1267 storage.set_api_key("anthropic", "key1".to_string());
1268 storage.set_api_key("openai", "key2".to_string());
1269
1270 let all = storage.get_all();
1271 assert_eq!(all.len(), 2);
1272 }
1273
1274 #[test]
1275 fn test_clear() {
1276 let storage = AuthStorage::in_memory();
1277 storage.set_api_key("anthropic", "key".to_string());
1278 assert!(storage.has("anthropic"));
1279
1280 storage.clear();
1281 assert!(!storage.has("anthropic"));
1282 }
1283
1284 #[test]
1285 fn test_remove_runtime_key() {
1286 let storage = AuthStorage::in_memory();
1287 storage.set_api_key("anthropic", "stored".to_string());
1288 storage.set_runtime_key("anthropic", "runtime".to_string());
1289
1290 assert_eq!(
1291 storage.get_api_key("anthropic"),
1292 Some("runtime".to_string())
1293 );
1294
1295 storage.remove_runtime_key("anthropic");
1296 assert_eq!(storage.get_api_key("anthropic"), Some("stored".to_string()));
1297 }
1298
1299 #[test]
1300 fn test_auth_credential_is_expired() {
1301 let api_key_cred = AuthCredential::ApiKey {
1303 key: "test".to_string(),
1304 };
1305 assert!(!api_key_cred.is_expired());
1306
1307 let future_time = now_secs() + 3600;
1309 let oauth_cred = AuthCredential::OAuth {
1310 access_token: "token".to_string(),
1311 refresh_token: Some("refresh".to_string()),
1312 expires_at: future_time,
1313 scopes: None,
1314 provider_data: None,
1315 };
1316 assert!(!oauth_cred.is_expired());
1317
1318 let oauth_cred_expired = AuthCredential::OAuth {
1320 access_token: "token".to_string(),
1321 refresh_token: Some("refresh".to_string()),
1322 expires_at: 0,
1323 scopes: None,
1324 provider_data: None,
1325 };
1326 assert!(oauth_cred_expired.is_expired());
1327 }
1328
1329 #[test]
1330 fn test_auth_credential_needs_refresh() {
1331 let future_time = now_secs() + 120; let oauth_cred = AuthCredential::OAuth {
1335 access_token: "token".to_string(),
1336 refresh_token: Some("refresh".to_string()),
1337 expires_at: future_time,
1338 scopes: None,
1339 provider_data: None,
1340 };
1341 assert!(!oauth_cred.needs_refresh());
1342
1343 let soon = now_secs() + 30;
1345 let oauth_soon = AuthCredential::OAuth {
1346 access_token: "token".to_string(),
1347 refresh_token: Some("refresh".to_string()),
1348 expires_at: soon,
1349 scopes: None,
1350 provider_data: None,
1351 };
1352 assert!(oauth_soon.needs_refresh());
1353
1354 let no_refresh = AuthCredential::OAuth {
1356 access_token: "token".to_string(),
1357 refresh_token: None,
1358 expires_at: future_time,
1359 scopes: None,
1360 provider_data: None,
1361 };
1362 assert!(!no_refresh.needs_refresh());
1363
1364 let api_key_cred = AuthCredential::ApiKey {
1366 key: "test".to_string(),
1367 };
1368 assert!(!api_key_cred.needs_refresh());
1369 }
1370
1371 #[test]
1372 fn test_auth_credential_access_token() {
1373 let future_time = now_secs() + 3600;
1374
1375 let oauth_cred = AuthCredential::OAuth {
1376 access_token: "valid_token".to_string(),
1377 refresh_token: Some("refresh".to_string()),
1378 expires_at: future_time,
1379 scopes: None,
1380 provider_data: None,
1381 };
1382 assert_eq!(oauth_cred.access_token(), Some("valid_token"));
1383
1384 let expired_cred = AuthCredential::OAuth {
1386 access_token: "expired_token".to_string(),
1387 refresh_token: Some("refresh".to_string()),
1388 expires_at: 0,
1389 scopes: None,
1390 provider_data: None,
1391 };
1392 assert!(expired_cred.access_token().is_none());
1393
1394 let api_key_cred = AuthCredential::ApiKey {
1396 key: "api_key_token".to_string(),
1397 };
1398 assert!(api_key_cred.access_token().is_none());
1399 }
1400
1401 #[test]
1402 fn test_get_oauth_credential() {
1403 let storage = AuthStorage::in_memory();
1404 storage.set_oauth(
1405 "provider",
1406 "access".to_string(),
1407 Some("refresh".to_string()),
1408 u64::MAX,
1409 );
1410
1411 let cred = storage.get_oauth_credential("provider");
1412 assert!(cred.is_some());
1413 assert!(matches!(cred.unwrap(), AuthCredential::OAuth { .. }));
1414 }
1415
1416 #[test]
1417 fn test_has_oauth_with_refresh() {
1418 let storage = AuthStorage::in_memory();
1419
1420 storage.set_oauth(
1422 "with_refresh",
1423 "access".to_string(),
1424 Some("refresh".to_string()),
1425 u64::MAX,
1426 );
1427 assert!(storage.has_oauth_with_refresh("with_refresh"));
1428
1429 storage.set_oauth("without_refresh", "access".to_string(), None, u64::MAX);
1431 assert!(!storage.has_oauth_with_refresh("without_refresh"));
1432
1433 storage.set_api_key("apikey_provider", "key".to_string());
1435 assert!(!storage.has_oauth_with_refresh("apikey_provider"));
1436 }
1437
1438 #[test]
1439 fn test_set_oauth_full() {
1440 let storage = AuthStorage::in_memory();
1441 storage.set_oauth_full(
1442 "provider",
1443 "access_token".to_string(),
1444 Some("refresh_token".to_string()),
1445 3600,
1446 Some("read write".to_string()),
1447 Some(serde_json::json!({"extra": "data"})),
1448 );
1449
1450 let cred = storage.get_oauth_credential("provider");
1451 assert!(cred.is_some());
1452 if let AuthCredential::OAuth {
1453 scopes,
1454 provider_data,
1455 ..
1456 } = cred.unwrap()
1457 {
1458 assert_eq!(scopes, Some("read write".to_string()));
1459 assert!(provider_data.is_some());
1460 } else {
1461 panic!("Expected OAuth credential");
1462 }
1463 }
1464
1465 #[test]
1466 fn test_session_token() {
1467 let storage = AuthStorage::in_memory();
1468 storage.set_session(
1469 "browser",
1470 "session-token-123".to_string(),
1471 0, Some(serde_json::json!({"user": "test"})),
1473 );
1474
1475 assert!(storage.has("browser"));
1476 assert_eq!(
1477 storage.get_api_key("browser"),
1478 Some("session-token-123".to_string())
1479 );
1480
1481 let cred = storage.get("browser").unwrap();
1482 assert!(matches!(cred, AuthCredential::Session { .. }));
1483 assert!(cred.access_token().is_some());
1484 }
1485
1486 #[test]
1487 fn test_session_token_expired() {
1488 let storage = AuthStorage::in_memory();
1489 storage.set_session("browser", "session-token".to_string(), 1, None);
1490
1491 assert!(storage.get_api_key("browser").is_none());
1493 }
1494
1495 #[test]
1496 fn test_credential_validation() {
1497 let valid = AuthCredential::ApiKey {
1499 key: "sk-valid".to_string(),
1500 };
1501 assert!(valid.validate().is_ok());
1502
1503 let empty = AuthCredential::ApiKey {
1505 key: "".to_string(),
1506 };
1507 assert!(empty.validate().is_err());
1508
1509 let placeholder = AuthCredential::ApiKey {
1511 key: "your-api-key-here".to_string(),
1512 };
1513 assert!(placeholder.validate().is_err());
1514
1515 let valid_oauth = AuthCredential::OAuth {
1517 access_token: "token".to_string(),
1518 refresh_token: None,
1519 expires_at: now_secs() + 3600,
1520 scopes: None,
1521 provider_data: None,
1522 };
1523 assert!(valid_oauth.validate().is_ok());
1524
1525 let invalid_oauth = AuthCredential::OAuth {
1527 access_token: "".to_string(),
1528 refresh_token: None,
1529 expires_at: 1000,
1530 scopes: None,
1531 provider_data: None,
1532 };
1533 assert!(invalid_oauth.validate().is_err());
1534 }
1535
1536 #[test]
1537 fn test_validate_all() {
1538 let storage = AuthStorage::in_memory();
1539 storage.set_api_key("valid", "sk-good".to_string());
1540 storage.set_api_key("empty", "".to_string());
1541
1542 let errors = storage.validate_all();
1543 assert_eq!(errors.len(), 1);
1544 assert_eq!(errors[0].0, "empty");
1545 }
1546
1547 #[test]
1548 fn test_update_oauth_tokens() {
1549 let storage = AuthStorage::in_memory();
1550 storage.set_oauth(
1551 "provider",
1552 "old-access".to_string(),
1553 Some("old-refresh".to_string()),
1554 now_secs() + 3600,
1555 );
1556
1557 storage
1558 .update_oauth_tokens(
1559 "provider",
1560 "new-access".to_string(),
1561 Some("new-refresh".to_string()),
1562 now_secs() + 7200,
1563 )
1564 .unwrap();
1565
1566 let key = storage.get_api_key("provider");
1567 assert_eq!(key, Some("new-access".to_string()));
1568 }
1569
1570 #[test]
1571 fn test_update_oauth_tokens_wrong_type() {
1572 let storage = AuthStorage::in_memory();
1573 storage.set_api_key("provider", "key".to_string());
1574
1575 let result = storage.update_oauth_tokens(
1576 "provider",
1577 "new-access".to_string(),
1578 None,
1579 now_secs() + 3600,
1580 );
1581 assert!(result.is_err());
1582 }
1583
1584 #[test]
1585 fn test_migrate_provider() {
1586 let storage = AuthStorage::in_memory();
1587 storage.set_api_key("old-provider", "key123".to_string());
1588 storage
1589 .migrate_provider("old-provider", "new-provider")
1590 .unwrap();
1591
1592 assert!(!storage.has("old-provider"));
1593 assert!(storage.has("new-provider"));
1594 assert_eq!(
1595 storage.get_api_key("new-provider"),
1596 Some("key123".to_string())
1597 );
1598 }
1599
1600 #[test]
1601 fn test_migrate_provider_not_found() {
1602 let storage = AuthStorage::in_memory();
1603 let result = storage.migrate_provider("nonexistent", "target");
1604 assert!(result.is_err());
1605 }
1606
1607 #[test]
1608 fn test_error_draining() {
1609 let storage = AuthStorage::in_memory();
1610 let errors = storage.drain_errors();
1611 assert!(errors.is_empty());
1612 }
1613
1614 #[test]
1615 fn test_fallback_resolver() {
1616 let storage = AuthStorage::in_memory();
1617 storage.set_fallback_resolver(Arc::new(FnFallbackResolver::new(Box::new(|provider| {
1618 if provider == "custom" {
1619 Some("custom-key-from-config".to_string())
1620 } else {
1621 None
1622 }
1623 }))));
1624
1625 assert_eq!(
1626 storage.get_api_key("custom"),
1627 Some("custom-key-from-config".to_string())
1628 );
1629 assert!(storage.get_api_key("unknown").is_none());
1630
1631 storage.clear_fallback_resolver();
1633 assert!(storage.get_api_key("custom").is_none());
1634 }
1635
1636 #[test]
1637 fn test_get_api_key_with_options() {
1638 let storage = AuthStorage::in_memory();
1639 storage.set_fallback_resolver(Arc::new(FnFallbackResolver::new(Box::new(|_| {
1640 Some("fallback-key".to_string())
1641 }))));
1642
1643 assert_eq!(
1645 storage.get_api_key_with_options("test", true),
1646 Some("fallback-key".to_string())
1647 );
1648
1649 assert!(storage.get_api_key_with_options("test", false).is_none());
1651 }
1652
1653 #[test]
1654 fn test_configured_providers() {
1655 let storage = AuthStorage::in_memory();
1656 storage.set_api_key("openai", "key".to_string());
1657 storage.set_api_key("anthropic", "key".to_string());
1658
1659 let providers = storage.configured_providers();
1660 assert!(providers.len() >= 2);
1661 let mut sorted = providers.clone();
1663 sorted.sort();
1664 assert_eq!(providers, sorted);
1665 }
1666
1667 #[test]
1668 fn test_has_multiple_providers() {
1669 let storage = AuthStorage::in_memory();
1670 assert!(!storage.has_multiple_providers());
1671
1672 storage.set_api_key("openai", "key1".to_string());
1673 assert!(!storage.has_multiple_providers());
1674
1675 storage.set_api_key("anthropic", "key2".to_string());
1676 assert!(storage.has_multiple_providers());
1677 }
1678
1679 #[test]
1680 fn test_set_and_get_credential() {
1681 let storage = AuthStorage::in_memory();
1682 let cred = AuthCredential::Session {
1683 token: "abc".to_string(),
1684 expires_at: 0,
1685 metadata: None,
1686 };
1687 storage.set("custom", cred);
1688 let retrieved = storage.get("custom");
1689 assert!(retrieved.is_some());
1690 assert!(matches!(retrieved.unwrap(), AuthCredential::Session { .. }));
1691 }
1692
1693 #[test]
1694 fn test_credential_type_name() {
1695 assert_eq!(
1696 AuthCredential::ApiKey {
1697 key: "k".to_string()
1698 }
1699 .type_name(),
1700 "api_key"
1701 );
1702 assert_eq!(
1703 AuthCredential::OAuth {
1704 access_token: "t".to_string(),
1705 refresh_token: None,
1706 expires_at: 0,
1707 scopes: None,
1708 provider_data: None,
1709 }
1710 .type_name(),
1711 "oauth"
1712 );
1713 assert_eq!(
1714 AuthCredential::Session {
1715 token: "t".to_string(),
1716 expires_at: 0,
1717 metadata: None,
1718 }
1719 .type_name(),
1720 "session"
1721 );
1722 }
1723}