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
1035impl oxi_sdk::ports::AuthProvider for AuthStorage {
1052 fn get_api_key(
1053 &self,
1054 provider: &str,
1055 ) -> std::pin::Pin<
1056 Box<
1057 dyn std::future::Future<Output = Result<Option<String>, oxi_sdk::SdkError>> + Send + '_,
1058 >,
1059 > {
1060 let result = self.get_api_key(provider);
1061 Box::pin(async move { Ok(result) })
1062 }
1063
1064 fn get_api_key_sync(&self, provider: &str) -> Result<Option<String>, oxi_sdk::SdkError> {
1069 Ok(self.get_api_key(provider))
1070 }
1071
1072 fn set_api_key(
1073 &self,
1074 provider: &str,
1075 key: &str,
1076 ) -> std::pin::Pin<
1077 Box<dyn std::future::Future<Output = Result<(), oxi_sdk::SdkError>> + Send + '_>,
1078 > {
1079 AuthStorage::set_api_key(self, provider, key.to_string());
1080 Box::pin(async { Ok(()) })
1081 }
1082
1083 fn delete_api_key(
1084 &self,
1085 provider: &str,
1086 ) -> std::pin::Pin<
1087 Box<dyn std::future::Future<Output = Result<(), oxi_sdk::SdkError>> + Send + '_>,
1088 > {
1089 self.credentials.write().remove(provider);
1090 let _ = self.persist();
1091 Box::pin(async { Ok(()) })
1092 }
1093
1094 fn get_oauth(
1095 &self,
1096 provider: &str,
1097 ) -> std::pin::Pin<
1098 Box<
1099 dyn std::future::Future<
1100 Output = Result<Option<oxi_sdk::ports::OAuthToken>, oxi_sdk::SdkError>,
1101 > + Send
1102 + '_,
1103 >,
1104 > {
1105 let result = self
1106 .get_oauth_credential(provider)
1107 .and_then(|cred| match cred {
1108 AuthCredential::OAuth {
1109 access_token,
1110 refresh_token,
1111 expires_at,
1112 ..
1113 } => Some(oxi_sdk::ports::OAuthToken {
1114 access_token,
1115 refresh_token,
1116 expires_at: chrono::DateTime::from_timestamp(expires_at as i64, 0),
1117 token_type: Some("Bearer".to_string()),
1118 scope: None,
1119 }),
1120 _ => None,
1121 });
1122 Box::pin(async move { Ok(result) })
1123 }
1124
1125 fn set_oauth(
1126 &self,
1127 provider: &str,
1128 token: oxi_sdk::ports::OAuthToken,
1129 ) -> std::pin::Pin<
1130 Box<dyn std::future::Future<Output = Result<(), oxi_sdk::SdkError>> + Send + '_>,
1131 > {
1132 let expires_at = token
1133 .expires_at
1134 .and_then(|dt| dt.timestamp().max(0).try_into().ok())
1135 .unwrap_or(0);
1136 AuthStorage::set_oauth(
1137 self,
1138 provider,
1139 token.access_token,
1140 token.refresh_token,
1141 expires_at,
1142 );
1143 Box::pin(async { Ok(()) })
1144 }
1145
1146 fn list_providers(
1147 &self,
1148 ) -> std::pin::Pin<
1149 Box<dyn std::future::Future<Output = Result<Vec<String>, oxi_sdk::SdkError>> + Send + '_>,
1150 > {
1151 let result = AuthStorage::list_providers(self);
1152 Box::pin(async move { Ok(result) })
1153 }
1154}
1155
1156fn now_secs() -> u64 {
1161 std::time::SystemTime::now()
1162 .duration_since(std::time::UNIX_EPOCH)
1163 .map(|d| d.as_secs())
1164 .unwrap_or(0)
1165}
1166
1167#[allow(unexpected_cfgs)]
1182#[deprecated(note = "keyring cargo feature is not wired in Cargo.toml; \
1183 this module is currently a no-op fallback. See docs/PORT_GUIDE.md \
1184 and the F-4 audit note in oxi-cli/src/store/auth_storage.rs.")]
1185pub mod keyring_support {
1186 use super::*;
1187
1188 #[cfg(feature = "keyring")]
1190 pub fn get_keyring_secret(service: &str, account: &str) -> Option<String> {
1191 use keyring::Entry;
1192 Entry::new(service, account)
1193 .ok()
1194 .and_then(|entry| entry.get_password().ok())
1195 }
1196
1197 #[cfg(feature = "keyring")]
1199 pub fn set_keyring_secret(service: &str, account: &str, secret: &str) -> AuthResult<()> {
1200 use keyring::Entry;
1201 Entry::new(service, account)
1202 .map_err(|e| AuthError::KeyringError(e.to_string()))?
1203 .set_password(secret)
1204 .map_err(|e| AuthError::KeyringError(e.to_string()))
1205 }
1206
1207 #[cfg(feature = "keyring")]
1209 pub fn delete_keyring_secret(service: &str, account: &str) -> AuthResult<()> {
1210 use keyring::Entry;
1211 Entry::new(service, account)
1212 .map_err(|e| AuthError::KeyringError(e.to_string()))?
1213 .delete_credential()
1214 .map_err(|e| AuthError::KeyringError(e.to_string()))
1215 }
1216
1217 #[cfg(not(feature = "keyring"))]
1219 pub fn get_keyring_secret(_service: &str, _account: &str) -> Option<String> {
1223 None
1224 }
1225
1226 #[cfg(not(feature = "keyring"))]
1227 pub fn set_keyring_secret(_service: &str, _account: &str, _secret: &str) -> AuthResult<()> {
1231 Err(AuthError::KeyringError(
1232 "Keyring support not compiled".to_string(),
1233 ))
1234 }
1235
1236 #[cfg(not(feature = "keyring"))]
1237 pub fn delete_keyring_secret(_service: &str, _account: &str) -> AuthResult<()> {
1241 Err(AuthError::KeyringError(
1242 "Keyring support not compiled".to_string(),
1243 ))
1244 }
1245}
1246
1247pub fn shared_auth_storage() -> Arc<AuthStorage> {
1257 static STORAGE: OnceLock<Arc<AuthStorage>> = OnceLock::new();
1258 STORAGE
1259 .get_or_init(|| {
1260 let storage = Arc::new(AuthStorage::new());
1261 storage.set_fallback_resolver(Arc::new(EnvVarFallbackResolver));
1264 storage
1265 })
1266 .clone()
1267}
1268
1269#[cfg(test)]
1274mod tests {
1275 use super::*;
1276
1277 #[test]
1278 fn test_auth_storage_new() {
1279 let storage = AuthStorage::in_memory();
1280 assert!(!storage.has("anthropic"));
1281 }
1282
1283 #[test]
1284 fn test_set_and_get_api_key() {
1285 let storage = AuthStorage::in_memory();
1286 storage.set_api_key("anthropic", "sk-test123".to_string());
1287 assert!(storage.has("anthropic"));
1288 assert_eq!(
1289 storage.get_api_key("anthropic"),
1290 Some("sk-test123".to_string())
1291 );
1292 }
1293
1294 #[test]
1295 fn test_runtime_override() {
1296 let storage = AuthStorage::in_memory();
1297 storage.set_api_key("anthropic", "stored-key".to_string());
1298 storage.set_runtime_key("anthropic", "runtime-key".to_string());
1299
1300 assert_eq!(
1302 storage.get_api_key("anthropic"),
1303 Some("runtime-key".to_string())
1304 );
1305 }
1306
1307 #[test]
1308 fn test_remove_credential() {
1309 let storage = AuthStorage::in_memory();
1310 storage.set_api_key("anthropic", "sk-test123".to_string());
1311 assert!(storage.has("anthropic"));
1312
1313 storage.remove("anthropic");
1314 assert!(!storage.has("anthropic"));
1315 }
1316
1317 #[test]
1318 fn test_auth_status() {
1319 let storage = AuthStorage::in_memory();
1320 storage.set_api_key("anthropic", "sk-test123".to_string());
1321
1322 let status = storage.get_status("anthropic");
1323 assert!(status.configured);
1324 assert_eq!(status.source, Some("stored".to_string()));
1325 assert_eq!(status.label, Some("api_key".to_string()));
1326 }
1327
1328 #[test]
1329 fn test_auth_status_display() {
1330 let status = AuthStatus {
1331 configured: true,
1332 source: Some("stored".to_string()),
1333 label: Some("api_key".to_string()),
1334 };
1335 let display = format!("{}", status);
1336 assert_eq!(display, "stored (api_key)");
1337
1338 let no_config = AuthStatus {
1339 configured: false,
1340 source: None,
1341 label: None,
1342 };
1343 assert_eq!(format!("{}", no_config), "not configured");
1344 }
1345
1346 #[test]
1347 fn test_list_providers() {
1348 let storage = AuthStorage::in_memory();
1349 storage.set_api_key("anthropic", "key1".to_string());
1350 storage.set_api_key("openai", "key2".to_string());
1351
1352 let providers = storage.list_providers();
1353 assert!(providers.contains(&"anthropic".to_string()));
1354 assert!(providers.contains(&"openai".to_string()));
1355 }
1356
1357 #[test]
1358 fn test_oauth_credential() {
1359 let storage = AuthStorage::in_memory();
1360 storage.set_oauth(
1361 "provider",
1362 "access123".to_string(),
1363 Some("refresh456".to_string()),
1364 u64::MAX,
1365 );
1366
1367 assert!(storage.has("provider"));
1368 assert_eq!(
1369 storage.get_api_key("provider"),
1370 Some("access123".to_string())
1371 );
1372 }
1373
1374 #[test]
1375 fn test_expired_oauth_token() {
1376 let storage = AuthStorage::in_memory();
1377 storage.set_oauth("provider", "access123".to_string(), None, 0);
1379
1380 let key = storage.get_api_key("provider");
1382 assert!(key.is_none());
1383 }
1384
1385 #[test]
1386 fn test_get_all_credentials() {
1387 let storage = AuthStorage::in_memory();
1388 storage.set_api_key("anthropic", "key1".to_string());
1389 storage.set_api_key("openai", "key2".to_string());
1390
1391 let all = storage.get_all();
1392 assert_eq!(all.len(), 2);
1393 }
1394
1395 #[test]
1396 fn test_clear() {
1397 let storage = AuthStorage::in_memory();
1398 storage.set_api_key("anthropic", "key".to_string());
1399 assert!(storage.has("anthropic"));
1400
1401 storage.clear();
1402 assert!(!storage.has("anthropic"));
1403 }
1404
1405 #[test]
1406 fn test_remove_runtime_key() {
1407 let storage = AuthStorage::in_memory();
1408 storage.set_api_key("anthropic", "stored".to_string());
1409 storage.set_runtime_key("anthropic", "runtime".to_string());
1410
1411 assert_eq!(
1412 storage.get_api_key("anthropic"),
1413 Some("runtime".to_string())
1414 );
1415
1416 storage.remove_runtime_key("anthropic");
1417 assert_eq!(storage.get_api_key("anthropic"), Some("stored".to_string()));
1418 }
1419
1420 #[test]
1421 fn test_auth_credential_is_expired() {
1422 let api_key_cred = AuthCredential::ApiKey {
1424 key: "test".to_string(),
1425 };
1426 assert!(!api_key_cred.is_expired());
1427
1428 let future_time = now_secs() + 3600;
1430 let oauth_cred = AuthCredential::OAuth {
1431 access_token: "token".to_string(),
1432 refresh_token: Some("refresh".to_string()),
1433 expires_at: future_time,
1434 scopes: None,
1435 provider_data: None,
1436 };
1437 assert!(!oauth_cred.is_expired());
1438
1439 let oauth_cred_expired = AuthCredential::OAuth {
1441 access_token: "token".to_string(),
1442 refresh_token: Some("refresh".to_string()),
1443 expires_at: 0,
1444 scopes: None,
1445 provider_data: None,
1446 };
1447 assert!(oauth_cred_expired.is_expired());
1448 }
1449
1450 #[test]
1451 fn test_auth_credential_needs_refresh() {
1452 let future_time = now_secs() + 120; let oauth_cred = AuthCredential::OAuth {
1456 access_token: "token".to_string(),
1457 refresh_token: Some("refresh".to_string()),
1458 expires_at: future_time,
1459 scopes: None,
1460 provider_data: None,
1461 };
1462 assert!(!oauth_cred.needs_refresh());
1463
1464 let soon = now_secs() + 30;
1466 let oauth_soon = AuthCredential::OAuth {
1467 access_token: "token".to_string(),
1468 refresh_token: Some("refresh".to_string()),
1469 expires_at: soon,
1470 scopes: None,
1471 provider_data: None,
1472 };
1473 assert!(oauth_soon.needs_refresh());
1474
1475 let no_refresh = AuthCredential::OAuth {
1477 access_token: "token".to_string(),
1478 refresh_token: None,
1479 expires_at: future_time,
1480 scopes: None,
1481 provider_data: None,
1482 };
1483 assert!(!no_refresh.needs_refresh());
1484
1485 let api_key_cred = AuthCredential::ApiKey {
1487 key: "test".to_string(),
1488 };
1489 assert!(!api_key_cred.needs_refresh());
1490 }
1491
1492 #[test]
1493 fn test_auth_credential_access_token() {
1494 let future_time = now_secs() + 3600;
1495
1496 let oauth_cred = AuthCredential::OAuth {
1497 access_token: "valid_token".to_string(),
1498 refresh_token: Some("refresh".to_string()),
1499 expires_at: future_time,
1500 scopes: None,
1501 provider_data: None,
1502 };
1503 assert_eq!(oauth_cred.access_token(), Some("valid_token"));
1504
1505 let expired_cred = AuthCredential::OAuth {
1507 access_token: "expired_token".to_string(),
1508 refresh_token: Some("refresh".to_string()),
1509 expires_at: 0,
1510 scopes: None,
1511 provider_data: None,
1512 };
1513 assert!(expired_cred.access_token().is_none());
1514
1515 let api_key_cred = AuthCredential::ApiKey {
1517 key: "api_key_token".to_string(),
1518 };
1519 assert!(api_key_cred.access_token().is_none());
1520 }
1521
1522 #[test]
1523 fn test_get_oauth_credential() {
1524 let storage = AuthStorage::in_memory();
1525 storage.set_oauth(
1526 "provider",
1527 "access".to_string(),
1528 Some("refresh".to_string()),
1529 u64::MAX,
1530 );
1531
1532 let cred = storage.get_oauth_credential("provider");
1533 assert!(cred.is_some());
1534 assert!(matches!(cred.unwrap(), AuthCredential::OAuth { .. }));
1535 }
1536
1537 #[test]
1538 fn test_has_oauth_with_refresh() {
1539 let storage = AuthStorage::in_memory();
1540
1541 storage.set_oauth(
1543 "with_refresh",
1544 "access".to_string(),
1545 Some("refresh".to_string()),
1546 u64::MAX,
1547 );
1548 assert!(storage.has_oauth_with_refresh("with_refresh"));
1549
1550 storage.set_oauth("without_refresh", "access".to_string(), None, u64::MAX);
1552 assert!(!storage.has_oauth_with_refresh("without_refresh"));
1553
1554 storage.set_api_key("apikey_provider", "key".to_string());
1556 assert!(!storage.has_oauth_with_refresh("apikey_provider"));
1557 }
1558
1559 #[test]
1560 fn test_set_oauth_full() {
1561 let storage = AuthStorage::in_memory();
1562 storage.set_oauth_full(
1563 "provider",
1564 "access_token".to_string(),
1565 Some("refresh_token".to_string()),
1566 3600,
1567 Some("read write".to_string()),
1568 Some(serde_json::json!({"extra": "data"})),
1569 );
1570
1571 let cred = storage.get_oauth_credential("provider");
1572 assert!(cred.is_some());
1573 if let AuthCredential::OAuth {
1574 scopes,
1575 provider_data,
1576 ..
1577 } = cred.unwrap()
1578 {
1579 assert_eq!(scopes, Some("read write".to_string()));
1580 assert!(provider_data.is_some());
1581 } else {
1582 panic!("Expected OAuth credential");
1583 }
1584 }
1585
1586 #[test]
1587 fn test_session_token() {
1588 let storage = AuthStorage::in_memory();
1589 storage.set_session(
1590 "browser",
1591 "session-token-123".to_string(),
1592 0, Some(serde_json::json!({"user": "test"})),
1594 );
1595
1596 assert!(storage.has("browser"));
1597 assert_eq!(
1598 storage.get_api_key("browser"),
1599 Some("session-token-123".to_string())
1600 );
1601
1602 let cred = storage.get("browser").unwrap();
1603 assert!(matches!(cred, AuthCredential::Session { .. }));
1604 assert!(cred.access_token().is_some());
1605 }
1606
1607 #[test]
1608 fn test_session_token_expired() {
1609 let storage = AuthStorage::in_memory();
1610 storage.set_session("browser", "session-token".to_string(), 1, None);
1611
1612 assert!(storage.get_api_key("browser").is_none());
1614 }
1615
1616 #[test]
1617 fn test_credential_validation() {
1618 let valid = AuthCredential::ApiKey {
1620 key: "sk-valid".to_string(),
1621 };
1622 assert!(valid.validate().is_ok());
1623
1624 let empty = AuthCredential::ApiKey {
1626 key: "".to_string(),
1627 };
1628 assert!(empty.validate().is_err());
1629
1630 let placeholder = AuthCredential::ApiKey {
1632 key: "your-api-key-here".to_string(),
1633 };
1634 assert!(placeholder.validate().is_err());
1635
1636 let valid_oauth = AuthCredential::OAuth {
1638 access_token: "token".to_string(),
1639 refresh_token: None,
1640 expires_at: now_secs() + 3600,
1641 scopes: None,
1642 provider_data: None,
1643 };
1644 assert!(valid_oauth.validate().is_ok());
1645
1646 let invalid_oauth = AuthCredential::OAuth {
1648 access_token: "".to_string(),
1649 refresh_token: None,
1650 expires_at: 1000,
1651 scopes: None,
1652 provider_data: None,
1653 };
1654 assert!(invalid_oauth.validate().is_err());
1655 }
1656
1657 #[test]
1658 fn test_validate_all() {
1659 let storage = AuthStorage::in_memory();
1660 storage.set_api_key("valid", "sk-good".to_string());
1661 storage.set_api_key("empty", "".to_string());
1662
1663 let errors = storage.validate_all();
1664 assert_eq!(errors.len(), 1);
1665 assert_eq!(errors[0].0, "empty");
1666 }
1667
1668 #[test]
1669 fn test_update_oauth_tokens() {
1670 let storage = AuthStorage::in_memory();
1671 storage.set_oauth(
1672 "provider",
1673 "old-access".to_string(),
1674 Some("old-refresh".to_string()),
1675 now_secs() + 3600,
1676 );
1677
1678 storage
1679 .update_oauth_tokens(
1680 "provider",
1681 "new-access".to_string(),
1682 Some("new-refresh".to_string()),
1683 now_secs() + 7200,
1684 )
1685 .unwrap();
1686
1687 let key = storage.get_api_key("provider");
1688 assert_eq!(key, Some("new-access".to_string()));
1689 }
1690
1691 #[test]
1692 fn test_update_oauth_tokens_wrong_type() {
1693 let storage = AuthStorage::in_memory();
1694 storage.set_api_key("provider", "key".to_string());
1695
1696 let result = storage.update_oauth_tokens(
1697 "provider",
1698 "new-access".to_string(),
1699 None,
1700 now_secs() + 3600,
1701 );
1702 assert!(result.is_err());
1703 }
1704
1705 #[test]
1706 fn test_migrate_provider() {
1707 let storage = AuthStorage::in_memory();
1708 storage.set_api_key("old-provider", "key123".to_string());
1709 storage
1710 .migrate_provider("old-provider", "new-provider")
1711 .unwrap();
1712
1713 assert!(!storage.has("old-provider"));
1714 assert!(storage.has("new-provider"));
1715 assert_eq!(
1716 storage.get_api_key("new-provider"),
1717 Some("key123".to_string())
1718 );
1719 }
1720
1721 #[test]
1722 fn test_migrate_provider_not_found() {
1723 let storage = AuthStorage::in_memory();
1724 let result = storage.migrate_provider("nonexistent", "target");
1725 assert!(result.is_err());
1726 }
1727
1728 #[test]
1729 fn test_error_draining() {
1730 let storage = AuthStorage::in_memory();
1731 let errors = storage.drain_errors();
1732 assert!(errors.is_empty());
1733 }
1734
1735 #[test]
1736 fn test_fallback_resolver() {
1737 let storage = AuthStorage::in_memory();
1738 storage.set_fallback_resolver(Arc::new(FnFallbackResolver::new(Box::new(|provider| {
1739 if provider == "custom" {
1740 Some("custom-key-from-config".to_string())
1741 } else {
1742 None
1743 }
1744 }))));
1745
1746 assert_eq!(
1747 storage.get_api_key("custom"),
1748 Some("custom-key-from-config".to_string())
1749 );
1750 assert!(storage.get_api_key("unknown").is_none());
1751
1752 storage.clear_fallback_resolver();
1754 assert!(storage.get_api_key("custom").is_none());
1755 }
1756
1757 #[test]
1758 fn test_get_api_key_with_options() {
1759 let storage = AuthStorage::in_memory();
1760 storage.set_fallback_resolver(Arc::new(FnFallbackResolver::new(Box::new(|_| {
1761 Some("fallback-key".to_string())
1762 }))));
1763
1764 assert_eq!(
1766 storage.get_api_key_with_options("test", true),
1767 Some("fallback-key".to_string())
1768 );
1769
1770 assert!(storage.get_api_key_with_options("test", false).is_none());
1772 }
1773
1774 #[test]
1775 fn test_configured_providers() {
1776 let storage = AuthStorage::in_memory();
1777 storage.set_api_key("openai", "key".to_string());
1778 storage.set_api_key("anthropic", "key".to_string());
1779
1780 let providers = storage.configured_providers();
1781 assert!(providers.len() >= 2);
1782 let mut sorted = providers.clone();
1784 sorted.sort();
1785 assert_eq!(providers, sorted);
1786 }
1787
1788 #[test]
1789 fn test_has_multiple_providers() {
1790 let storage = AuthStorage::in_memory();
1791 assert!(!storage.has_multiple_providers());
1792
1793 storage.set_api_key("openai", "key1".to_string());
1794 assert!(!storage.has_multiple_providers());
1795
1796 storage.set_api_key("anthropic", "key2".to_string());
1797 assert!(storage.has_multiple_providers());
1798 }
1799
1800 #[test]
1801 fn test_set_and_get_credential() {
1802 let storage = AuthStorage::in_memory();
1803 let cred = AuthCredential::Session {
1804 token: "abc".to_string(),
1805 expires_at: 0,
1806 metadata: None,
1807 };
1808 storage.set("custom", cred);
1809 let retrieved = storage.get("custom");
1810 assert!(retrieved.is_some());
1811 assert!(matches!(retrieved.unwrap(), AuthCredential::Session { .. }));
1812 }
1813
1814 #[test]
1815 fn test_credential_type_name() {
1816 assert_eq!(
1817 AuthCredential::ApiKey {
1818 key: "k".to_string()
1819 }
1820 .type_name(),
1821 "api_key"
1822 );
1823 assert_eq!(
1824 AuthCredential::OAuth {
1825 access_token: "t".to_string(),
1826 refresh_token: None,
1827 expires_at: 0,
1828 scopes: None,
1829 provider_data: None,
1830 }
1831 .type_name(),
1832 "oauth"
1833 );
1834 assert_eq!(
1835 AuthCredential::Session {
1836 token: "t".to_string(),
1837 expires_at: 0,
1838 metadata: None,
1839 }
1840 .type_name(),
1841 "session"
1842 );
1843 }
1844}