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 {
281 path: PathBuf,
282 cache: RwLock<Option<String>>,
283}
284
285impl FileAuthStorage {
286 pub fn new(path: PathBuf) -> Self {
288 Self {
289 path,
290 cache: RwLock::new(None),
291 }
292 }
293
294 pub fn default_path() -> Option<PathBuf> {
296 dirs::home_dir().map(|p| p.join(".oxi").join("auth.json"))
297 }
298
299 pub fn path(&self) -> &PathBuf {
301 &self.path
302 }
303}
304
305impl AuthStorageBackend for FileAuthStorage {
306 fn read(&self) -> AuthResult<Option<String>> {
307 if !self.path.exists() {
308 return Ok(None);
309 }
310
311 match std::fs::read_to_string(&self.path) {
312 Ok(content) => {
313 *self.cache.write() = Some(content.clone());
314 Ok(Some(content))
315 }
316 Err(e) => Err(AuthError::ReadError(e.to_string())),
317 }
318 }
319
320 fn write(&self, data: &str) -> AuthResult<()> {
321 if let Some(parent) = self.path.parent() {
323 std::fs::create_dir_all(parent).map_err(|e| AuthError::WriteError(e.to_string()))?;
324
325 #[cfg(unix)]
326 {
327 use std::os::unix::fs::PermissionsExt;
328 let perms = std::fs::Permissions::from_mode(0o700);
329 let _ = std::fs::set_permissions(parent, perms);
330 }
331 }
332
333 std::fs::write(&self.path, data).map_err(|e| AuthError::WriteError(e.to_string()))?;
335
336 #[cfg(unix)]
338 {
339 use std::os::unix::fs::PermissionsExt;
340 let perms = std::fs::Permissions::from_mode(0o600);
341 std::fs::set_permissions(&self.path, perms)
342 .map_err(|e| AuthError::WriteError(e.to_string()))?;
343 }
344
345 *self.cache.write() = Some(data.to_string());
346 Ok(())
347 }
348
349 fn delete(&self) -> AuthResult<()> {
350 if self.path.exists() {
351 std::fs::remove_file(&self.path).map_err(|e| AuthError::WriteError(e.to_string()))?;
352 }
353 *self.cache.write() = None;
354 Ok(())
355 }
356}
357
358pub struct MemoryAuthStorage {
364 data: RwLock<HashMap<String, AuthCredential>>,
365}
366
367impl MemoryAuthStorage {
368 pub fn new() -> Self {
370 Self {
371 data: RwLock::new(HashMap::new()),
372 }
373 }
374}
375
376impl Default for MemoryAuthStorage {
377 fn default() -> Self {
378 Self::new()
379 }
380}
381
382impl AuthStorageBackend for MemoryAuthStorage {
383 fn read(&self) -> AuthResult<Option<String>> {
384 Ok(None)
386 }
387
388 fn write(&self, _data: &str) -> AuthResult<()> {
389 Ok(())
390 }
391
392 fn delete(&self) -> AuthResult<()> {
393 self.data.write().clear();
394 Ok(())
395 }
396}
397
398pub trait FallbackResolver: Send + Sync {
404 fn resolve(&self, provider: &str) -> Option<String>;
406}
407
408pub struct FnFallbackResolver {
410 #[allow(clippy::type_complexity)]
411 f: Box<dyn Fn(&str) -> Option<String> + Send + Sync>,
412}
413
414impl FnFallbackResolver {
415 #[allow(clippy::type_complexity)]
417 pub fn new(f: Box<dyn Fn(&str) -> Option<String> + Send + Sync>) -> Self {
418 Self { f }
419 }
420}
421
422impl FallbackResolver for FnFallbackResolver {
423 fn resolve(&self, provider: &str) -> Option<String> {
424 (self.f)(provider)
425 }
426}
427
428pub struct EnvVarFallbackResolver;
433
434impl FallbackResolver for EnvVarFallbackResolver {
435 fn resolve(&self, provider: &str) -> Option<String> {
436 let builtin = oxi_ai::get_builtin_provider(provider)?;
438 let key = builtin.env_key;
439
440 if let Ok(val) = std::env::var(key)
442 && !val.is_empty()
443 {
444 return Some(val);
445 }
446
447 for extra in builtin.extra_env_keys {
449 if let Ok(val) = std::env::var(extra)
450 && !val.is_empty()
451 {
452 return Some(val);
453 }
454 }
455
456 None
457 }
458}
459
460pub struct AuthStorage {
474 file_storage: Option<Arc<dyn AuthStorageBackend>>,
476 credentials: RwLock<HashMap<String, AuthCredential>>,
478 runtime_overrides: RwLock<HashMap<String, String>>,
480 fallback_resolver: RwLock<Option<Arc<dyn FallbackResolver>>>,
482 errors: RwLock<Vec<AuthError>>,
484 load_error: RwLock<Option<AuthError>>,
486 plaintext_warned: OnceLock<()>,
488}
489
490impl AuthStorage {
491 pub fn new() -> Self {
493 let file_storage = FileAuthStorage::default_path()
494 .map(|p| Arc::new(FileAuthStorage::new(p)) as Arc<dyn AuthStorageBackend>);
495
496 let credentials = if let Some(ref storage) = file_storage {
497 match storage.read() {
498 Ok(Some(content)) => serde_json::from_str(&content).unwrap_or_default(),
499 _ => HashMap::new(),
500 }
501 } else {
502 HashMap::new()
503 };
504
505 Self {
506 file_storage,
507 credentials: RwLock::new(credentials),
508 runtime_overrides: RwLock::new(HashMap::new()),
509 fallback_resolver: RwLock::new(None),
510 errors: RwLock::new(Vec::new()),
511 load_error: RwLock::new(None),
512 plaintext_warned: OnceLock::new(),
513 }
514 }
515
516 pub fn with_backend(backend: impl AuthStorageBackend + 'static) -> Self {
518 let credentials = match backend.read() {
519 Ok(Some(content)) => serde_json::from_str(&content).unwrap_or_default(),
520 _ => HashMap::new(),
521 };
522
523 Self {
524 file_storage: Some(Arc::new(backend)),
525 credentials: RwLock::new(credentials),
526 runtime_overrides: RwLock::new(HashMap::new()),
527 fallback_resolver: RwLock::new(None),
528 errors: RwLock::new(Vec::new()),
529 load_error: RwLock::new(None),
530 plaintext_warned: OnceLock::new(),
531 }
532 }
533
534 pub fn in_memory() -> Self {
536 Self {
537 file_storage: None,
538 credentials: RwLock::new(HashMap::new()),
539 runtime_overrides: RwLock::new(HashMap::new()),
540 fallback_resolver: RwLock::new(None),
541 errors: RwLock::new(Vec::new()),
542 load_error: RwLock::new(None),
543 plaintext_warned: OnceLock::new(),
544 }
545 }
546
547 pub fn default_path() -> Option<PathBuf> {
549 FileAuthStorage::default_path()
550 }
551
552 pub fn set_runtime_key(&self, provider: &str, api_key: String) {
558 self.runtime_overrides
559 .write()
560 .insert(provider.to_string(), api_key);
561 }
562
563 pub fn remove_runtime_key(&self, provider: &str) {
565 self.runtime_overrides.write().remove(provider);
566 }
567
568 pub fn set_fallback_resolver(&self, resolver: Arc<dyn FallbackResolver>) {
575 *self.fallback_resolver.write() = Some(resolver);
576 }
577
578 pub fn clear_fallback_resolver(&self) {
580 *self.fallback_resolver.write() = None;
581 }
582
583 pub fn has_auth(&self, provider: &str) -> bool {
589 if self.runtime_overrides.read().contains_key(provider) {
590 return true;
591 }
592 if self.credentials.read().contains_key(provider) {
593 return true;
594 }
595 if let Some(ref resolver) = *self.fallback_resolver.read()
596 && resolver.resolve(provider).is_some()
597 {
598 return true;
599 }
600 false
601 }
602
603 pub fn get_status(&self, provider: &str) -> AuthStatus {
605 if self.runtime_overrides.read().contains_key(provider) {
606 return AuthStatus {
607 configured: false,
608 source: Some("runtime".to_string()),
609 label: Some("--api-key".to_string()),
610 };
611 }
612
613 if let Some(cred) = self.credentials.read().get(provider) {
614 return AuthStatus {
615 configured: true,
616 source: Some("stored".to_string()),
617 label: Some(cred.type_name().to_string()),
618 };
619 }
620
621 if let Some(ref resolver) = *self.fallback_resolver.read()
622 && resolver.resolve(provider).is_some()
623 {
624 return AuthStatus {
625 configured: false,
626 source: Some("fallback".to_string()),
627 label: Some("custom provider config".to_string()),
628 };
629 }
630
631 AuthStatus {
632 configured: false,
633 source: None,
634 label: None,
635 }
636 }
637
638 pub fn get_api_key(&self, provider: &str) -> Option<String> {
647 self.get_api_key_with_options(provider, true)
648 }
649
650 pub fn get_api_key_with_options(
652 &self,
653 provider: &str,
654 include_fallback: bool,
655 ) -> Option<String> {
656 if let Some(key) = self.runtime_overrides.read().get(provider) {
658 return Some(key.clone());
659 }
660
661 if let Some(cred) = self.credentials.read().get(provider) {
663 return match cred {
664 AuthCredential::ApiKey { key } => Some(key.clone()),
665 AuthCredential::OAuth {
666 access_token,
667 expires_at,
668 ..
669 } => {
670 if *expires_at > now_secs() {
671 Some(access_token.clone())
672 } else {
673 None
675 }
676 }
677 AuthCredential::Session {
678 token, expires_at, ..
679 } => {
680 if *expires_at == 0 || *expires_at > now_secs() {
681 Some(token.clone())
682 } else {
683 None
684 }
685 }
686 };
687 }
688
689 if let Some(builtin) = oxi_ai::register_builtins::get_builtin_provider(provider) {
694 let env_key = builtin.env_key;
695 let credentials = self.credentials.read();
696 for other in oxi_ai::register_builtins::get_builtin_providers() {
697 if other.name == provider {
698 continue; }
700 if other.env_key == env_key
701 && let Some(cred) = credentials.get(other.name)
702 {
703 return match cred {
704 AuthCredential::ApiKey { key } => Some(key.clone()),
705 AuthCredential::OAuth {
706 access_token,
707 expires_at,
708 ..
709 } => {
710 if *expires_at > now_secs() {
711 Some(access_token.clone())
712 } else {
713 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 }
729
730 if include_fallback && let Some(ref resolver) = *self.fallback_resolver.read() {
732 return resolver.resolve(provider);
733 }
734
735 None
736 }
737
738 pub fn set_api_key(&self, provider: &str, key: String) {
744 self.credentials
745 .write()
746 .insert(provider.to_string(), AuthCredential::ApiKey { key });
747 if let Err(e) = self.persist() {
748 tracing::warn!("Failed to persist API key for '{}': {}", provider, e);
749 }
750 }
751
752 pub fn set_oauth(
754 &self,
755 provider: &str,
756 access_token: String,
757 refresh_token: Option<String>,
758 expires_at: u64,
759 ) {
760 self.set_oauth_full(
761 provider,
762 access_token,
763 refresh_token,
764 expires_at,
765 None,
766 None,
767 );
768 }
769
770 pub fn set_oauth_full(
772 &self,
773 provider: &str,
774 access_token: String,
775 refresh_token: Option<String>,
776 expires_at: u64,
777 scopes: Option<String>,
778 provider_data: Option<serde_json::Value>,
779 ) {
780 self.credentials.write().insert(
781 provider.to_string(),
782 AuthCredential::OAuth {
783 access_token,
784 refresh_token,
785 expires_at,
786 scopes,
787 provider_data,
788 },
789 );
790 if let Err(e) = self.persist() {
791 tracing::warn!("Failed to persist OAuth token for '{}': {}", provider, e);
792 }
793 }
794
795 pub fn set_session(
797 &self,
798 provider: &str,
799 token: String,
800 expires_at: u64,
801 metadata: Option<serde_json::Value>,
802 ) {
803 self.credentials.write().insert(
804 provider.to_string(),
805 AuthCredential::Session {
806 token,
807 expires_at,
808 metadata,
809 },
810 );
811 if let Err(e) = self.persist() {
812 tracing::warn!("Failed to persist session for '{}': {}", provider, e);
813 }
814 }
815
816 pub fn update_oauth_tokens(
818 &self,
819 provider: &str,
820 new_access_token: String,
821 new_refresh_token: Option<String>,
822 new_expires_at: u64,
823 ) -> AuthResult<()> {
824 let mut creds = self.credentials.write();
825 let cred = creds
826 .get_mut(provider)
827 .ok_or_else(|| AuthError::NotFound(provider.to_string()))?;
828
829 match cred {
830 AuthCredential::OAuth {
831 access_token,
832 refresh_token,
833 expires_at,
834 ..
835 } => {
836 *access_token = new_access_token;
837 *refresh_token = new_refresh_token;
838 *expires_at = new_expires_at;
839 }
840 _ => {
841 return Err(AuthError::InvalidFormat(format!(
842 "Provider '{}' does not have OAuth credentials",
843 provider
844 )));
845 }
846 }
847
848 drop(creds);
849 if let Err(e) = self.persist() {
850 tracing::warn!(
851 "Failed to persist OAuth token update for '{}': {}",
852 provider,
853 e
854 );
855 }
856 Ok(())
857 }
858
859 pub fn get(&self, provider: &str) -> Option<AuthCredential> {
865 self.credentials.read().get(provider).cloned()
866 }
867
868 pub fn get_oauth_credential(&self, provider: &str) -> Option<AuthCredential> {
870 self.credentials.read().get(provider).cloned()
871 }
872
873 pub fn has_oauth_with_refresh(&self, provider: &str) -> bool {
875 if let Some(cred) = self.credentials.read().get(provider) {
876 matches!(
877 cred,
878 AuthCredential::OAuth {
879 refresh_token: Some(_),
880 ..
881 }
882 )
883 } else {
884 false
885 }
886 }
887
888 pub fn set(&self, provider: &str, credential: AuthCredential) {
894 self.credentials
895 .write()
896 .insert(provider.to_string(), credential);
897 if let Err(e) = self.persist() {
898 tracing::warn!("Failed to persist credential for '{}': {}", provider, e);
899 }
900 }
901
902 pub fn remove(&self, provider: &str) {
904 self.credentials.write().remove(provider);
905 if let Err(e) = self.persist() {
906 tracing::warn!("Failed to persist after removing '{}': {}", provider, e);
907 }
908 }
909
910 pub fn list_providers(&self) -> Vec<String> {
912 self.credentials.read().keys().cloned().collect()
913 }
914
915 pub fn has(&self, provider: &str) -> bool {
917 self.credentials.read().contains_key(provider)
918 }
919
920 pub fn get_all(&self) -> HashMap<String, AuthCredential> {
922 self.credentials.read().clone()
923 }
924
925 pub fn clear(&self) {
927 self.credentials.write().clear();
928 if let Err(e) = self.persist() {
929 tracing::warn!("Failed to persist after clearing credentials: {}", e);
930 }
931 }
932
933 pub fn reload(&self) {
939 if let Some(ref storage) = self.file_storage {
940 match storage.read() {
941 Ok(Some(content)) => {
942 if let Ok(creds) = serde_json::from_str(&content) {
943 *self.credentials.write() = creds;
944 }
945 *self.load_error.write() = None;
946 }
947 Ok(None) => {
948 self.credentials.write().clear();
949 *self.load_error.write() = None;
950 }
951 Err(e) => {
952 *self.load_error.write() = Some(e);
953 self.record_error(AuthError::ReadError(
954 "Failed to reload auth storage".to_string(),
955 ));
956 }
957 }
958 }
959 }
960
961 #[allow(unexpected_cfgs)]
963 fn persist(&self) -> Result<(), String> {
964 if let Some(ref storage) = self.file_storage {
965 let creds = self.credentials.read();
966 if let Ok(json) = serde_json::to_string_pretty(&*creds) {
967 self.plaintext_warned.get_or_init(|| {
975 tracing::warn!(
976 "Auth credentials are stored in plaintext at \
977 ~/.oxi/auth.json (mode 0600). For OS-keyring \
978 support, see the `oxi-auth-keyring` crate or \
979 the OXI_KEYRING=1 docs at docs/PORT_GUIDE.md."
980 );
981 });
982
983 if let Err(e) = storage.write(&json) {
984 tracing::error!("Failed to persist auth storage: {}", e);
985 self.record_error(e);
986 return Err("persist failed".to_string());
987 }
988 }
989 }
990 Ok(())
991 }
992
993 fn record_error(&self, error: AuthError) {
999 self.errors.write().push(error);
1000 }
1001
1002 pub fn drain_errors(&self) -> Vec<AuthError> {
1004 let mut errors = self.errors.write();
1005 std::mem::take(&mut *errors)
1006 }
1007
1008 pub fn load_error(&self) -> Option<AuthError> {
1010 self.load_error.read().clone()
1011 }
1012
1013 pub fn validate_all(&self) -> Vec<(String, CredentialValidationError)> {
1019 let creds = self.credentials.read();
1020 let mut results = Vec::new();
1021 for (provider, cred) in creds.iter() {
1022 if let Err(e) = cred.validate() {
1023 results.push((provider.clone(), e));
1024 }
1025 }
1026 results
1027 }
1028
1029 pub fn validate(&self, provider: &str) -> Result<(), CredentialValidationError> {
1031 let creds = self.credentials.read();
1032 let cred = creds.get(provider).ok_or_else(|| {
1033 CredentialValidationError::EmptyField(format!(
1034 "no credential for provider '{}'",
1035 provider
1036 ))
1037 })?;
1038 cred.validate()
1039 }
1040
1041 pub fn configured_providers(&self) -> Vec<String> {
1047 let mut providers: Vec<String> = self.credentials.read().keys().cloned().collect();
1048 providers.sort();
1049 providers
1050 }
1051
1052 pub fn has_multiple_providers(&self) -> bool {
1054 self.credentials.read().len() > 1
1055 }
1056
1057 pub fn primary_provider(&self) -> Option<String> {
1059 let creds = self.credentials.read();
1060 creds.keys().next().cloned()
1061 }
1062
1063 pub fn migrate_provider(&self, from: &str, to: &str) -> AuthResult<()> {
1065 let mut creds = self.credentials.write();
1066 let cred = creds
1067 .remove(from)
1068 .ok_or_else(|| AuthError::NotFound(from.to_string()))?;
1069 creds.insert(to.to_string(), cred);
1070 drop(creds);
1071 let _ = self.persist();
1072 Ok(())
1073 }
1074}
1075
1076impl Default for AuthStorage {
1077 fn default() -> Self {
1078 Self::new()
1079 }
1080}
1081
1082impl oxi_sdk::ports::AuthProvider for AuthStorage {
1099 fn get_api_key(
1100 &self,
1101 provider: &str,
1102 ) -> std::pin::Pin<
1103 Box<
1104 dyn std::future::Future<Output = Result<Option<String>, oxi_sdk::SdkError>> + Send + '_,
1105 >,
1106 > {
1107 let result = self.get_api_key(provider);
1108 Box::pin(async move { Ok(result) })
1109 }
1110
1111 fn get_api_key_sync(&self, provider: &str) -> Result<Option<String>, oxi_sdk::SdkError> {
1116 Ok(self.get_api_key(provider))
1117 }
1118
1119 fn set_api_key(
1120 &self,
1121 provider: &str,
1122 key: &str,
1123 ) -> std::pin::Pin<
1124 Box<dyn std::future::Future<Output = Result<(), oxi_sdk::SdkError>> + Send + '_>,
1125 > {
1126 AuthStorage::set_api_key(self, provider, key.to_string());
1127 Box::pin(async { Ok(()) })
1128 }
1129
1130 fn delete_api_key(
1131 &self,
1132 provider: &str,
1133 ) -> std::pin::Pin<
1134 Box<dyn std::future::Future<Output = Result<(), oxi_sdk::SdkError>> + Send + '_>,
1135 > {
1136 self.credentials.write().remove(provider);
1137 let _ = self.persist();
1138 Box::pin(async { Ok(()) })
1139 }
1140
1141 fn get_oauth(
1142 &self,
1143 provider: &str,
1144 ) -> std::pin::Pin<
1145 Box<
1146 dyn std::future::Future<
1147 Output = Result<Option<oxi_sdk::ports::OAuthToken>, oxi_sdk::SdkError>,
1148 > + Send
1149 + '_,
1150 >,
1151 > {
1152 let result = self
1153 .get_oauth_credential(provider)
1154 .and_then(|cred| match cred {
1155 AuthCredential::OAuth {
1156 access_token,
1157 refresh_token,
1158 expires_at,
1159 ..
1160 } => Some(oxi_sdk::ports::OAuthToken {
1161 access_token,
1162 refresh_token,
1163 expires_at: chrono::DateTime::from_timestamp(expires_at as i64, 0),
1164 token_type: Some("Bearer".to_string()),
1165 scope: None,
1166 }),
1167 _ => None,
1168 });
1169 Box::pin(async move { Ok(result) })
1170 }
1171
1172 fn set_oauth(
1173 &self,
1174 provider: &str,
1175 token: oxi_sdk::ports::OAuthToken,
1176 ) -> std::pin::Pin<
1177 Box<dyn std::future::Future<Output = Result<(), oxi_sdk::SdkError>> + Send + '_>,
1178 > {
1179 let expires_at = token
1180 .expires_at
1181 .and_then(|dt| dt.timestamp().max(0).try_into().ok())
1182 .unwrap_or(0);
1183 AuthStorage::set_oauth(
1184 self,
1185 provider,
1186 token.access_token,
1187 token.refresh_token,
1188 expires_at,
1189 );
1190 Box::pin(async { Ok(()) })
1191 }
1192
1193 fn list_providers(
1194 &self,
1195 ) -> std::pin::Pin<
1196 Box<dyn std::future::Future<Output = Result<Vec<String>, oxi_sdk::SdkError>> + Send + '_>,
1197 > {
1198 let result = AuthStorage::list_providers(self);
1199 Box::pin(async move { Ok(result) })
1200 }
1201}
1202
1203fn now_secs() -> u64 {
1208 std::time::SystemTime::now()
1209 .duration_since(std::time::UNIX_EPOCH)
1210 .map(|d| d.as_secs())
1211 .unwrap_or(0)
1212}
1213
1214#[allow(unexpected_cfgs)]
1229#[deprecated(note = "keyring cargo feature is not wired in Cargo.toml; \
1230 this module is currently a no-op fallback. See docs/PORT_GUIDE.md \
1231 and the F-4 audit note in oxi-cli/src/store/auth_storage.rs.")]
1232pub mod keyring_support {
1233 use super::*;
1234
1235 #[cfg(feature = "keyring")]
1237 pub fn get_keyring_secret(service: &str, account: &str) -> Option<String> {
1238 use keyring::Entry;
1239 Entry::new(service, account)
1240 .ok()
1241 .and_then(|entry| entry.get_password().ok())
1242 }
1243
1244 #[cfg(feature = "keyring")]
1246 pub fn set_keyring_secret(service: &str, account: &str, secret: &str) -> AuthResult<()> {
1247 use keyring::Entry;
1248 Entry::new(service, account)
1249 .map_err(|e| AuthError::KeyringError(e.to_string()))?
1250 .set_password(secret)
1251 .map_err(|e| AuthError::KeyringError(e.to_string()))
1252 }
1253
1254 #[cfg(feature = "keyring")]
1256 pub fn delete_keyring_secret(service: &str, account: &str) -> AuthResult<()> {
1257 use keyring::Entry;
1258 Entry::new(service, account)
1259 .map_err(|e| AuthError::KeyringError(e.to_string()))?
1260 .delete_credential()
1261 .map_err(|e| AuthError::KeyringError(e.to_string()))
1262 }
1263
1264 #[cfg(not(feature = "keyring"))]
1266 pub fn get_keyring_secret(_service: &str, _account: &str) -> Option<String> {
1270 None
1271 }
1272
1273 #[cfg(not(feature = "keyring"))]
1274 pub fn set_keyring_secret(_service: &str, _account: &str, _secret: &str) -> AuthResult<()> {
1278 Err(AuthError::KeyringError(
1279 "Keyring support not compiled".to_string(),
1280 ))
1281 }
1282
1283 #[cfg(not(feature = "keyring"))]
1284 pub fn delete_keyring_secret(_service: &str, _account: &str) -> AuthResult<()> {
1288 Err(AuthError::KeyringError(
1289 "Keyring support not compiled".to_string(),
1290 ))
1291 }
1292}
1293
1294pub fn shared_auth_storage() -> Arc<AuthStorage> {
1304 static STORAGE: OnceLock<Arc<AuthStorage>> = OnceLock::new();
1305 STORAGE
1306 .get_or_init(|| {
1307 let storage = Arc::new(AuthStorage::new());
1308 storage.set_fallback_resolver(Arc::new(EnvVarFallbackResolver));
1311 storage
1312 })
1313 .clone()
1314}
1315
1316#[cfg(test)]
1321mod tests {
1322 use super::*;
1323
1324 #[test]
1325 fn test_auth_storage_new() {
1326 let storage = AuthStorage::in_memory();
1327 assert!(!storage.has("anthropic"));
1328 }
1329
1330 #[test]
1331 fn test_set_and_get_api_key() {
1332 let storage = AuthStorage::in_memory();
1333 storage.set_api_key("anthropic", "sk-test123".to_string());
1334 assert!(storage.has("anthropic"));
1335 assert_eq!(
1336 storage.get_api_key("anthropic"),
1337 Some("sk-test123".to_string())
1338 );
1339 }
1340
1341 #[test]
1342 fn test_runtime_override() {
1343 let storage = AuthStorage::in_memory();
1344 storage.set_api_key("anthropic", "stored-key".to_string());
1345 storage.set_runtime_key("anthropic", "runtime-key".to_string());
1346
1347 assert_eq!(
1349 storage.get_api_key("anthropic"),
1350 Some("runtime-key".to_string())
1351 );
1352 }
1353
1354 #[test]
1355 fn test_remove_credential() {
1356 let storage = AuthStorage::in_memory();
1357 storage.set_api_key("anthropic", "sk-test123".to_string());
1358 assert!(storage.has("anthropic"));
1359
1360 storage.remove("anthropic");
1361 assert!(!storage.has("anthropic"));
1362 }
1363
1364 #[test]
1365 fn test_auth_status() {
1366 let storage = AuthStorage::in_memory();
1367 storage.set_api_key("anthropic", "sk-test123".to_string());
1368
1369 let status = storage.get_status("anthropic");
1370 assert!(status.configured);
1371 assert_eq!(status.source, Some("stored".to_string()));
1372 assert_eq!(status.label, Some("api_key".to_string()));
1373 }
1374
1375 #[test]
1376 fn test_auth_status_display() {
1377 let status = AuthStatus {
1378 configured: true,
1379 source: Some("stored".to_string()),
1380 label: Some("api_key".to_string()),
1381 };
1382 let display = format!("{}", status);
1383 assert_eq!(display, "stored (api_key)");
1384
1385 let no_config = AuthStatus {
1386 configured: false,
1387 source: None,
1388 label: None,
1389 };
1390 assert_eq!(format!("{}", no_config), "not configured");
1391 }
1392
1393 #[test]
1394 fn test_list_providers() {
1395 let storage = AuthStorage::in_memory();
1396 storage.set_api_key("anthropic", "key1".to_string());
1397 storage.set_api_key("openai", "key2".to_string());
1398
1399 let providers = storage.list_providers();
1400 assert!(providers.contains(&"anthropic".to_string()));
1401 assert!(providers.contains(&"openai".to_string()));
1402 }
1403
1404 #[test]
1405 fn test_oauth_credential() {
1406 let storage = AuthStorage::in_memory();
1407 storage.set_oauth(
1408 "provider",
1409 "access123".to_string(),
1410 Some("refresh456".to_string()),
1411 u64::MAX,
1412 );
1413
1414 assert!(storage.has("provider"));
1415 assert_eq!(
1416 storage.get_api_key("provider"),
1417 Some("access123".to_string())
1418 );
1419 }
1420
1421 #[test]
1422 fn test_expired_oauth_token() {
1423 let storage = AuthStorage::in_memory();
1424 storage.set_oauth("provider", "access123".to_string(), None, 0);
1426
1427 let key = storage.get_api_key("provider");
1429 assert!(key.is_none());
1430 }
1431
1432 #[test]
1433 fn test_get_all_credentials() {
1434 let storage = AuthStorage::in_memory();
1435 storage.set_api_key("anthropic", "key1".to_string());
1436 storage.set_api_key("openai", "key2".to_string());
1437
1438 let all = storage.get_all();
1439 assert_eq!(all.len(), 2);
1440 }
1441
1442 #[test]
1443 fn test_clear() {
1444 let storage = AuthStorage::in_memory();
1445 storage.set_api_key("anthropic", "key".to_string());
1446 assert!(storage.has("anthropic"));
1447
1448 storage.clear();
1449 assert!(!storage.has("anthropic"));
1450 }
1451
1452 #[test]
1453 fn test_remove_runtime_key() {
1454 let storage = AuthStorage::in_memory();
1455 storage.set_api_key("anthropic", "stored".to_string());
1456 storage.set_runtime_key("anthropic", "runtime".to_string());
1457
1458 assert_eq!(
1459 storage.get_api_key("anthropic"),
1460 Some("runtime".to_string())
1461 );
1462
1463 storage.remove_runtime_key("anthropic");
1464 assert_eq!(storage.get_api_key("anthropic"), Some("stored".to_string()));
1465 }
1466
1467 #[test]
1468 fn test_auth_credential_is_expired() {
1469 let api_key_cred = AuthCredential::ApiKey {
1471 key: "test".to_string(),
1472 };
1473 assert!(!api_key_cred.is_expired());
1474
1475 let future_time = now_secs() + 3600;
1477 let oauth_cred = AuthCredential::OAuth {
1478 access_token: "token".to_string(),
1479 refresh_token: Some("refresh".to_string()),
1480 expires_at: future_time,
1481 scopes: None,
1482 provider_data: None,
1483 };
1484 assert!(!oauth_cred.is_expired());
1485
1486 let oauth_cred_expired = AuthCredential::OAuth {
1488 access_token: "token".to_string(),
1489 refresh_token: Some("refresh".to_string()),
1490 expires_at: 0,
1491 scopes: None,
1492 provider_data: None,
1493 };
1494 assert!(oauth_cred_expired.is_expired());
1495 }
1496
1497 #[test]
1498 fn test_auth_credential_needs_refresh() {
1499 let future_time = now_secs() + 120; let oauth_cred = AuthCredential::OAuth {
1503 access_token: "token".to_string(),
1504 refresh_token: Some("refresh".to_string()),
1505 expires_at: future_time,
1506 scopes: None,
1507 provider_data: None,
1508 };
1509 assert!(!oauth_cred.needs_refresh());
1510
1511 let soon = now_secs() + 30;
1513 let oauth_soon = AuthCredential::OAuth {
1514 access_token: "token".to_string(),
1515 refresh_token: Some("refresh".to_string()),
1516 expires_at: soon,
1517 scopes: None,
1518 provider_data: None,
1519 };
1520 assert!(oauth_soon.needs_refresh());
1521
1522 let no_refresh = AuthCredential::OAuth {
1524 access_token: "token".to_string(),
1525 refresh_token: None,
1526 expires_at: future_time,
1527 scopes: None,
1528 provider_data: None,
1529 };
1530 assert!(!no_refresh.needs_refresh());
1531
1532 let api_key_cred = AuthCredential::ApiKey {
1534 key: "test".to_string(),
1535 };
1536 assert!(!api_key_cred.needs_refresh());
1537 }
1538
1539 #[test]
1540 fn test_auth_credential_access_token() {
1541 let future_time = now_secs() + 3600;
1542
1543 let oauth_cred = AuthCredential::OAuth {
1544 access_token: "valid_token".to_string(),
1545 refresh_token: Some("refresh".to_string()),
1546 expires_at: future_time,
1547 scopes: None,
1548 provider_data: None,
1549 };
1550 assert_eq!(oauth_cred.access_token(), Some("valid_token"));
1551
1552 let expired_cred = AuthCredential::OAuth {
1554 access_token: "expired_token".to_string(),
1555 refresh_token: Some("refresh".to_string()),
1556 expires_at: 0,
1557 scopes: None,
1558 provider_data: None,
1559 };
1560 assert!(expired_cred.access_token().is_none());
1561
1562 let api_key_cred = AuthCredential::ApiKey {
1564 key: "api_key_token".to_string(),
1565 };
1566 assert!(api_key_cred.access_token().is_none());
1567 }
1568
1569 #[test]
1570 fn test_get_oauth_credential() {
1571 let storage = AuthStorage::in_memory();
1572 storage.set_oauth(
1573 "provider",
1574 "access".to_string(),
1575 Some("refresh".to_string()),
1576 u64::MAX,
1577 );
1578
1579 let cred = storage.get_oauth_credential("provider");
1580 assert!(cred.is_some());
1581 assert!(matches!(cred.unwrap(), AuthCredential::OAuth { .. }));
1582 }
1583
1584 #[test]
1585 fn test_has_oauth_with_refresh() {
1586 let storage = AuthStorage::in_memory();
1587
1588 storage.set_oauth(
1590 "with_refresh",
1591 "access".to_string(),
1592 Some("refresh".to_string()),
1593 u64::MAX,
1594 );
1595 assert!(storage.has_oauth_with_refresh("with_refresh"));
1596
1597 storage.set_oauth("without_refresh", "access".to_string(), None, u64::MAX);
1599 assert!(!storage.has_oauth_with_refresh("without_refresh"));
1600
1601 storage.set_api_key("apikey_provider", "key".to_string());
1603 assert!(!storage.has_oauth_with_refresh("apikey_provider"));
1604 }
1605
1606 #[test]
1607 fn test_set_oauth_full() {
1608 let storage = AuthStorage::in_memory();
1609 storage.set_oauth_full(
1610 "provider",
1611 "access_token".to_string(),
1612 Some("refresh_token".to_string()),
1613 3600,
1614 Some("read write".to_string()),
1615 Some(serde_json::json!({"extra": "data"})),
1616 );
1617
1618 let cred = storage.get_oauth_credential("provider");
1619 assert!(cred.is_some());
1620 if let AuthCredential::OAuth {
1621 scopes,
1622 provider_data,
1623 ..
1624 } = cred.unwrap()
1625 {
1626 assert_eq!(scopes, Some("read write".to_string()));
1627 assert!(provider_data.is_some());
1628 } else {
1629 panic!("Expected OAuth credential");
1630 }
1631 }
1632
1633 #[test]
1634 fn test_session_token() {
1635 let storage = AuthStorage::in_memory();
1636 storage.set_session(
1637 "browser",
1638 "session-token-123".to_string(),
1639 0, Some(serde_json::json!({"user": "test"})),
1641 );
1642
1643 assert!(storage.has("browser"));
1644 assert_eq!(
1645 storage.get_api_key("browser"),
1646 Some("session-token-123".to_string())
1647 );
1648
1649 let cred = storage.get("browser").unwrap();
1650 assert!(matches!(cred, AuthCredential::Session { .. }));
1651 assert!(cred.access_token().is_some());
1652 }
1653
1654 #[test]
1655 fn test_session_token_expired() {
1656 let storage = AuthStorage::in_memory();
1657 storage.set_session("browser", "session-token".to_string(), 1, None);
1658
1659 assert!(storage.get_api_key("browser").is_none());
1661 }
1662
1663 #[test]
1664 fn test_credential_validation() {
1665 let valid = AuthCredential::ApiKey {
1667 key: "sk-valid".to_string(),
1668 };
1669 assert!(valid.validate().is_ok());
1670
1671 let empty = AuthCredential::ApiKey {
1673 key: "".to_string(),
1674 };
1675 assert!(empty.validate().is_err());
1676
1677 let placeholder = AuthCredential::ApiKey {
1679 key: "your-api-key-here".to_string(),
1680 };
1681 assert!(placeholder.validate().is_err());
1682
1683 let valid_oauth = AuthCredential::OAuth {
1685 access_token: "token".to_string(),
1686 refresh_token: None,
1687 expires_at: now_secs() + 3600,
1688 scopes: None,
1689 provider_data: None,
1690 };
1691 assert!(valid_oauth.validate().is_ok());
1692
1693 let invalid_oauth = AuthCredential::OAuth {
1695 access_token: "".to_string(),
1696 refresh_token: None,
1697 expires_at: 1000,
1698 scopes: None,
1699 provider_data: None,
1700 };
1701 assert!(invalid_oauth.validate().is_err());
1702 }
1703
1704 #[test]
1705 fn test_validate_all() {
1706 let storage = AuthStorage::in_memory();
1707 storage.set_api_key("valid", "sk-good".to_string());
1708 storage.set_api_key("empty", "".to_string());
1709
1710 let errors = storage.validate_all();
1711 assert_eq!(errors.len(), 1);
1712 assert_eq!(errors[0].0, "empty");
1713 }
1714
1715 #[test]
1716 fn test_update_oauth_tokens() {
1717 let storage = AuthStorage::in_memory();
1718 storage.set_oauth(
1719 "provider",
1720 "old-access".to_string(),
1721 Some("old-refresh".to_string()),
1722 now_secs() + 3600,
1723 );
1724
1725 storage
1726 .update_oauth_tokens(
1727 "provider",
1728 "new-access".to_string(),
1729 Some("new-refresh".to_string()),
1730 now_secs() + 7200,
1731 )
1732 .unwrap();
1733
1734 let key = storage.get_api_key("provider");
1735 assert_eq!(key, Some("new-access".to_string()));
1736 }
1737
1738 #[test]
1739 fn test_update_oauth_tokens_wrong_type() {
1740 let storage = AuthStorage::in_memory();
1741 storage.set_api_key("provider", "key".to_string());
1742
1743 let result = storage.update_oauth_tokens(
1744 "provider",
1745 "new-access".to_string(),
1746 None,
1747 now_secs() + 3600,
1748 );
1749 assert!(result.is_err());
1750 }
1751
1752 #[test]
1753 fn test_migrate_provider() {
1754 let storage = AuthStorage::in_memory();
1755 storage.set_api_key("old-provider", "key123".to_string());
1756 storage
1757 .migrate_provider("old-provider", "new-provider")
1758 .unwrap();
1759
1760 assert!(!storage.has("old-provider"));
1761 assert!(storage.has("new-provider"));
1762 assert_eq!(
1763 storage.get_api_key("new-provider"),
1764 Some("key123".to_string())
1765 );
1766 }
1767
1768 #[test]
1769 fn test_migrate_provider_not_found() {
1770 let storage = AuthStorage::in_memory();
1771 let result = storage.migrate_provider("nonexistent", "target");
1772 assert!(result.is_err());
1773 }
1774
1775 #[test]
1776 fn test_error_draining() {
1777 let storage = AuthStorage::in_memory();
1778 let errors = storage.drain_errors();
1779 assert!(errors.is_empty());
1780 }
1781
1782 #[test]
1783 fn test_fallback_resolver() {
1784 let storage = AuthStorage::in_memory();
1785 storage.set_fallback_resolver(Arc::new(FnFallbackResolver::new(Box::new(|provider| {
1786 if provider == "custom" {
1787 Some("custom-key-from-config".to_string())
1788 } else {
1789 None
1790 }
1791 }))));
1792
1793 assert_eq!(
1794 storage.get_api_key("custom"),
1795 Some("custom-key-from-config".to_string())
1796 );
1797 assert!(storage.get_api_key("unknown").is_none());
1798
1799 storage.clear_fallback_resolver();
1801 assert!(storage.get_api_key("custom").is_none());
1802 }
1803
1804 #[test]
1805 fn test_get_api_key_with_options() {
1806 let storage = AuthStorage::in_memory();
1807 storage.set_fallback_resolver(Arc::new(FnFallbackResolver::new(Box::new(|_| {
1808 Some("fallback-key".to_string())
1809 }))));
1810
1811 assert_eq!(
1813 storage.get_api_key_with_options("test", true),
1814 Some("fallback-key".to_string())
1815 );
1816
1817 assert!(storage.get_api_key_with_options("test", false).is_none());
1819 }
1820
1821 #[test]
1822 fn test_configured_providers() {
1823 let storage = AuthStorage::in_memory();
1824 storage.set_api_key("openai", "key".to_string());
1825 storage.set_api_key("anthropic", "key".to_string());
1826
1827 let providers = storage.configured_providers();
1828 assert!(providers.len() >= 2);
1829 let mut sorted = providers.clone();
1831 sorted.sort();
1832 assert_eq!(providers, sorted);
1833 }
1834
1835 #[test]
1836 fn test_has_multiple_providers() {
1837 let storage = AuthStorage::in_memory();
1838 assert!(!storage.has_multiple_providers());
1839
1840 storage.set_api_key("openai", "key1".to_string());
1841 assert!(!storage.has_multiple_providers());
1842
1843 storage.set_api_key("anthropic", "key2".to_string());
1844 assert!(storage.has_multiple_providers());
1845 }
1846
1847 #[test]
1848 fn test_set_and_get_credential() {
1849 let storage = AuthStorage::in_memory();
1850 let cred = AuthCredential::Session {
1851 token: "abc".to_string(),
1852 expires_at: 0,
1853 metadata: None,
1854 };
1855 storage.set("custom", cred);
1856 let retrieved = storage.get("custom");
1857 assert!(retrieved.is_some());
1858 assert!(matches!(retrieved.unwrap(), AuthCredential::Session { .. }));
1859 }
1860
1861 #[test]
1862 fn test_credential_type_name() {
1863 assert_eq!(
1864 AuthCredential::ApiKey {
1865 key: "k".to_string()
1866 }
1867 .type_name(),
1868 "api_key"
1869 );
1870 assert_eq!(
1871 AuthCredential::OAuth {
1872 access_token: "t".to_string(),
1873 refresh_token: None,
1874 expires_at: 0,
1875 scopes: None,
1876 provider_data: None,
1877 }
1878 .type_name(),
1879 "oauth"
1880 );
1881 assert_eq!(
1882 AuthCredential::Session {
1883 token: "t".to_string(),
1884 expires_at: 0,
1885 metadata: None,
1886 }
1887 .type_name(),
1888 "session"
1889 );
1890 }
1891
1892 #[test]
1895 fn debug_impl_masks_api_key() {
1896 let cred = AuthCredential::ApiKey {
1897 key: "sk-super-secret-12345".to_string(),
1898 };
1899 let dbg = format!("{cred:?}");
1900 assert!(
1901 !dbg.contains("sk-super-secret-12345"),
1902 "Debug must not leak the API key, got: {dbg}"
1903 );
1904 assert!(
1905 dbg.contains("<redacted>"),
1906 "Debug must show <redacted> placeholder, got: {dbg}"
1907 );
1908 }
1909
1910 #[test]
1911 fn debug_impl_masks_oauth_tokens() {
1912 let cred = AuthCredential::OAuth {
1913 access_token: "ya29.access-token-value".to_string(),
1914 refresh_token: Some("ya29.refresh-token-value".to_string()),
1915 expires_at: 9999999999,
1916 scopes: None,
1917 provider_data: None,
1918 };
1919 let dbg = format!("{cred:?}");
1920 assert!(
1921 !dbg.contains("access-token-value"),
1922 "Debug must not leak the access token, got: {dbg}"
1923 );
1924 assert!(
1925 !dbg.contains("refresh-token-value"),
1926 "Debug must not leak the refresh token, got: {dbg}"
1927 );
1928 assert!(
1930 dbg.contains("9999999999"),
1931 "Debug must still show expires_at for diagnostics, got: {dbg}"
1932 );
1933 }
1934
1935 #[test]
1936 fn debug_impl_masks_session_token() {
1937 let cred = AuthCredential::Session {
1938 token: "sess-abc-def-ghi".to_string(),
1939 expires_at: 0,
1940 metadata: None,
1941 };
1942 let dbg = format!("{cred:?}");
1943 assert!(
1944 !dbg.contains("sess-abc-def-ghi"),
1945 "Debug must not leak the session token, got: {dbg}"
1946 );
1947 assert!(
1948 dbg.contains("<redacted>"),
1949 "Debug must show <redacted> placeholder, got: {dbg}"
1950 );
1951 }
1952}