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(".oxicode").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 = oxicode_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) = oxicode_ai::register_builtins::get_builtin_provider(provider) {
694 let env_key = builtin.env_key;
695 let credentials = self.credentials.read();
696 for other in oxicode_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 pub fn clear(&self) {
926 self.credentials.write().clear();
927 if let Err(e) = self.persist() {
928 tracing::warn!("Failed to persist after clearing credentials: {}", e);
929 }
930 }
931
932 pub fn reload(&self) {
938 if let Some(ref storage) = self.file_storage {
939 match storage.read() {
940 Ok(Some(content)) => {
941 if let Ok(creds) = serde_json::from_str(&content) {
942 *self.credentials.write() = creds;
943 }
944 *self.load_error.write() = None;
945 }
946 Ok(None) => {
947 self.credentials.write().clear();
948 *self.load_error.write() = None;
949 }
950 Err(e) => {
951 *self.load_error.write() = Some(e);
952 self.record_error(AuthError::ReadError(
953 "Failed to reload auth storage".to_string(),
954 ));
955 }
956 }
957 }
958 }
959
960 #[allow(unexpected_cfgs)]
962 fn persist(&self) -> Result<(), String> {
963 if let Some(ref storage) = self.file_storage {
964 let creds = self.credentials.read();
965 if let Ok(json) = serde_json::to_string_pretty(&*creds) {
966 self.plaintext_warned.get_or_init(|| {
974 tracing::warn!(
975 "Auth credentials are stored in plaintext at \
976 ~/.oxicode/auth.json (mode 0600). For OS-keyring \
977 support, see the `oxicode-auth-keyring` crate or \
978 the OXICODE_KEYRING=1 docs at docs/PORT_GUIDE.md."
979 );
980 });
981
982 if let Err(e) = storage.write(&json) {
983 tracing::error!("Failed to persist auth storage: {}", e);
984 self.record_error(e);
985 return Err("persist failed".to_string());
986 }
987 }
988 }
989 Ok(())
990 }
991
992 fn record_error(&self, error: AuthError) {
998 self.errors.write().push(error);
999 }
1000
1001 pub fn drain_errors(&self) -> Vec<AuthError> {
1003 let mut errors = self.errors.write();
1004 std::mem::take(&mut *errors)
1005 }
1006
1007 pub fn load_error(&self) -> Option<AuthError> {
1009 self.load_error.read().clone()
1010 }
1011
1012 pub fn validate_all(&self) -> Vec<(String, CredentialValidationError)> {
1018 let creds = self.credentials.read();
1019 let mut results = Vec::new();
1020 for (provider, cred) in creds.iter() {
1021 if let Err(e) = cred.validate() {
1022 results.push((provider.clone(), e));
1023 }
1024 }
1025 results
1026 }
1027
1028 pub fn validate(&self, provider: &str) -> Result<(), CredentialValidationError> {
1030 let creds = self.credentials.read();
1031 let cred = creds.get(provider).ok_or_else(|| {
1032 CredentialValidationError::EmptyField(format!(
1033 "no credential for provider '{}'",
1034 provider
1035 ))
1036 })?;
1037 cred.validate()
1038 }
1039
1040 pub fn configured_providers(&self) -> Vec<String> {
1046 let mut providers: Vec<String> = self.credentials.read().keys().cloned().collect();
1047 providers.sort();
1048 providers
1049 }
1050
1051 pub fn has_multiple_providers(&self) -> bool {
1053 self.credentials.read().len() > 1
1054 }
1055
1056 pub fn primary_provider(&self) -> Option<String> {
1058 let creds = self.credentials.read();
1059 creds.keys().next().cloned()
1060 }
1061
1062 pub fn migrate_provider(&self, from: &str, to: &str) -> AuthResult<()> {
1064 let mut creds = self.credentials.write();
1065 let cred = creds
1066 .remove(from)
1067 .ok_or_else(|| AuthError::NotFound(from.to_string()))?;
1068 creds.insert(to.to_string(), cred);
1069 drop(creds);
1070 let _ = self.persist();
1071 Ok(())
1072 }
1073}
1074
1075impl Default for AuthStorage {
1076 fn default() -> Self {
1077 Self::new()
1078 }
1079}
1080
1081impl oxicode_sdk::ports::AuthProvider for AuthStorage {
1098 fn get_api_key(
1099 &self,
1100 provider: &str,
1101 ) -> std::pin::Pin<
1102 Box<
1103 dyn std::future::Future<Output = Result<Option<String>, oxicode_sdk::SdkError>>
1104 + Send
1105 + '_,
1106 >,
1107 > {
1108 let result = self.get_api_key(provider);
1109 Box::pin(async move { Ok(result) })
1110 }
1111
1112 fn get_api_key_sync(&self, provider: &str) -> Result<Option<String>, oxicode_sdk::SdkError> {
1117 Ok(self.get_api_key(provider))
1118 }
1119
1120 fn set_api_key(
1121 &self,
1122 provider: &str,
1123 key: &str,
1124 ) -> std::pin::Pin<
1125 Box<dyn std::future::Future<Output = Result<(), oxicode_sdk::SdkError>> + Send + '_>,
1126 > {
1127 AuthStorage::set_api_key(self, provider, key.to_string());
1128 Box::pin(async { Ok(()) })
1129 }
1130
1131 fn delete_api_key(
1132 &self,
1133 provider: &str,
1134 ) -> std::pin::Pin<
1135 Box<dyn std::future::Future<Output = Result<(), oxicode_sdk::SdkError>> + Send + '_>,
1136 > {
1137 self.credentials.write().remove(provider);
1138 let _ = self.persist();
1139 Box::pin(async { Ok(()) })
1140 }
1141
1142 fn get_oauth(
1143 &self,
1144 provider: &str,
1145 ) -> std::pin::Pin<
1146 Box<
1147 dyn std::future::Future<
1148 Output = Result<Option<oxicode_sdk::ports::OAuthToken>, oxicode_sdk::SdkError>,
1149 > + Send
1150 + '_,
1151 >,
1152 > {
1153 let result = self
1154 .get_oauth_credential(provider)
1155 .and_then(|cred| match cred {
1156 AuthCredential::OAuth {
1157 access_token,
1158 refresh_token,
1159 expires_at,
1160 ..
1161 } => Some(oxicode_sdk::ports::OAuthToken {
1162 access_token,
1163 refresh_token,
1164 expires_at: chrono::DateTime::from_timestamp(expires_at as i64, 0),
1165 token_type: Some("Bearer".to_string()),
1166 scope: None,
1167 }),
1168 _ => None,
1169 });
1170 Box::pin(async move { Ok(result) })
1171 }
1172
1173 fn set_oauth(
1174 &self,
1175 provider: &str,
1176 token: oxicode_sdk::ports::OAuthToken,
1177 ) -> std::pin::Pin<
1178 Box<dyn std::future::Future<Output = Result<(), oxicode_sdk::SdkError>> + Send + '_>,
1179 > {
1180 let expires_at = token
1181 .expires_at
1182 .and_then(|dt| dt.timestamp().max(0).try_into().ok())
1183 .unwrap_or(0);
1184 AuthStorage::set_oauth(
1185 self,
1186 provider,
1187 token.access_token,
1188 token.refresh_token,
1189 expires_at,
1190 );
1191 Box::pin(async { Ok(()) })
1192 }
1193
1194 fn list_providers(
1195 &self,
1196 ) -> std::pin::Pin<
1197 Box<
1198 dyn std::future::Future<Output = Result<Vec<String>, oxicode_sdk::SdkError>>
1199 + Send
1200 + '_,
1201 >,
1202 > {
1203 let result = AuthStorage::list_providers(self);
1204 Box::pin(async move { Ok(result) })
1205 }
1206}
1207
1208fn now_secs() -> u64 {
1213 std::time::SystemTime::now()
1214 .duration_since(std::time::UNIX_EPOCH)
1215 .map(|d| d.as_secs())
1216 .unwrap_or(0)
1217}
1218
1219#[allow(unexpected_cfgs)]
1234#[deprecated(note = "keyring cargo feature is not wired in Cargo.toml; \
1235 this module is currently a no-op fallback. See docs/PORT_GUIDE.md \
1236 and the F-4 audit note in oxicode-cli/src/store/auth_storage.rs.")]
1237pub mod keyring_support {
1238 use super::*;
1239
1240 #[cfg(feature = "keyring")]
1242 pub fn get_keyring_secret(service: &str, account: &str) -> Option<String> {
1243 use keyring::Entry;
1244 Entry::new(service, account)
1245 .ok()
1246 .and_then(|entry| entry.get_password().ok())
1247 }
1248
1249 #[cfg(feature = "keyring")]
1251 pub fn set_keyring_secret(service: &str, account: &str, secret: &str) -> AuthResult<()> {
1252 use keyring::Entry;
1253 Entry::new(service, account)
1254 .map_err(|e| AuthError::KeyringError(e.to_string()))?
1255 .set_password(secret)
1256 .map_err(|e| AuthError::KeyringError(e.to_string()))
1257 }
1258
1259 #[cfg(feature = "keyring")]
1261 pub fn delete_keyring_secret(service: &str, account: &str) -> AuthResult<()> {
1262 use keyring::Entry;
1263 Entry::new(service, account)
1264 .map_err(|e| AuthError::KeyringError(e.to_string()))?
1265 .delete_credential()
1266 .map_err(|e| AuthError::KeyringError(e.to_string()))
1267 }
1268
1269 #[cfg(not(feature = "keyring"))]
1271 pub fn get_keyring_secret(_service: &str, _account: &str) -> Option<String> {
1275 None
1276 }
1277
1278 #[cfg(not(feature = "keyring"))]
1279 pub fn set_keyring_secret(_service: &str, _account: &str, _secret: &str) -> AuthResult<()> {
1283 Err(AuthError::KeyringError(
1284 "Keyring support not compiled".to_string(),
1285 ))
1286 }
1287
1288 #[cfg(not(feature = "keyring"))]
1289 pub fn delete_keyring_secret(_service: &str, _account: &str) -> AuthResult<()> {
1293 Err(AuthError::KeyringError(
1294 "Keyring support not compiled".to_string(),
1295 ))
1296 }
1297}
1298
1299pub fn shared_auth_storage() -> Arc<AuthStorage> {
1309 static STORAGE: OnceLock<Arc<AuthStorage>> = OnceLock::new();
1310 STORAGE
1311 .get_or_init(|| {
1312 let storage = Arc::new(AuthStorage::new());
1313 storage.set_fallback_resolver(Arc::new(EnvVarFallbackResolver));
1316 storage
1317 })
1318 .clone()
1319}
1320
1321#[cfg(test)]
1326mod tests {
1327 use super::*;
1328
1329 #[test]
1330 fn test_auth_storage_new() {
1331 let storage = AuthStorage::in_memory();
1332 assert!(!storage.has("anthropic"));
1333 }
1334
1335 #[test]
1336 fn test_set_and_get_api_key() {
1337 let storage = AuthStorage::in_memory();
1338 storage.set_api_key("anthropic", "sk-test123".to_string());
1339 assert!(storage.has("anthropic"));
1340 assert_eq!(
1341 storage.get_api_key("anthropic"),
1342 Some("sk-test123".to_string())
1343 );
1344 }
1345
1346 #[test]
1347 fn test_runtime_override() {
1348 let storage = AuthStorage::in_memory();
1349 storage.set_api_key("anthropic", "stored-key".to_string());
1350 storage.set_runtime_key("anthropic", "runtime-key".to_string());
1351
1352 assert_eq!(
1354 storage.get_api_key("anthropic"),
1355 Some("runtime-key".to_string())
1356 );
1357 }
1358
1359 #[test]
1360 fn test_remove_credential() {
1361 let storage = AuthStorage::in_memory();
1362 storage.set_api_key("anthropic", "sk-test123".to_string());
1363 assert!(storage.has("anthropic"));
1364
1365 storage.remove("anthropic");
1366 assert!(!storage.has("anthropic"));
1367 }
1368
1369 #[test]
1370 fn test_auth_status() {
1371 let storage = AuthStorage::in_memory();
1372 storage.set_api_key("anthropic", "sk-test123".to_string());
1373
1374 let status = storage.get_status("anthropic");
1375 assert!(status.configured);
1376 assert_eq!(status.source, Some("stored".to_string()));
1377 assert_eq!(status.label, Some("api_key".to_string()));
1378 }
1379
1380 #[test]
1381 fn test_auth_status_display() {
1382 let status = AuthStatus {
1383 configured: true,
1384 source: Some("stored".to_string()),
1385 label: Some("api_key".to_string()),
1386 };
1387 let display = format!("{}", status);
1388 assert_eq!(display, "stored (api_key)");
1389
1390 let no_config = AuthStatus {
1391 configured: false,
1392 source: None,
1393 label: None,
1394 };
1395 assert_eq!(format!("{}", no_config), "not configured");
1396 }
1397
1398 #[test]
1399 fn test_list_providers() {
1400 let storage = AuthStorage::in_memory();
1401 storage.set_api_key("anthropic", "key1".to_string());
1402 storage.set_api_key("openai", "key2".to_string());
1403
1404 let providers = storage.list_providers();
1405 assert!(providers.contains(&"anthropic".to_string()));
1406 assert!(providers.contains(&"openai".to_string()));
1407 }
1408
1409 #[test]
1410 fn test_oauth_credential() {
1411 let storage = AuthStorage::in_memory();
1412 storage.set_oauth(
1413 "provider",
1414 "access123".to_string(),
1415 Some("refresh456".to_string()),
1416 u64::MAX,
1417 );
1418
1419 assert!(storage.has("provider"));
1420 assert_eq!(
1421 storage.get_api_key("provider"),
1422 Some("access123".to_string())
1423 );
1424 }
1425
1426 #[test]
1427 fn test_expired_oauth_token() {
1428 let storage = AuthStorage::in_memory();
1429 storage.set_oauth("provider", "access123".to_string(), None, 0);
1431
1432 let key = storage.get_api_key("provider");
1434 assert!(key.is_none());
1435 }
1436
1437 #[test]
1438 fn test_get_all_credentials() {
1439 let storage = AuthStorage::in_memory();
1440 storage.set_api_key("anthropic", "key1".to_string());
1441 storage.set_api_key("openai", "key2".to_string());
1442
1443 let all = storage.get_all();
1444 assert_eq!(all.len(), 2);
1445 }
1446
1447 #[test]
1448 fn test_clear() {
1449 let storage = AuthStorage::in_memory();
1450 storage.set_api_key("anthropic", "key".to_string());
1451 assert!(storage.has("anthropic"));
1452
1453 storage.clear();
1454 assert!(!storage.has("anthropic"));
1455 }
1456
1457 #[test]
1458 fn test_remove_runtime_key() {
1459 let storage = AuthStorage::in_memory();
1460 storage.set_api_key("anthropic", "stored".to_string());
1461 storage.set_runtime_key("anthropic", "runtime".to_string());
1462
1463 assert_eq!(
1464 storage.get_api_key("anthropic"),
1465 Some("runtime".to_string())
1466 );
1467
1468 storage.remove_runtime_key("anthropic");
1469 assert_eq!(storage.get_api_key("anthropic"), Some("stored".to_string()));
1470 }
1471
1472 #[test]
1473 fn test_auth_credential_is_expired() {
1474 let api_key_cred = AuthCredential::ApiKey {
1476 key: "test".to_string(),
1477 };
1478 assert!(!api_key_cred.is_expired());
1479
1480 let future_time = now_secs() + 3600;
1482 let oauth_cred = AuthCredential::OAuth {
1483 access_token: "token".to_string(),
1484 refresh_token: Some("refresh".to_string()),
1485 expires_at: future_time,
1486 scopes: None,
1487 provider_data: None,
1488 };
1489 assert!(!oauth_cred.is_expired());
1490
1491 let oauth_cred_expired = AuthCredential::OAuth {
1493 access_token: "token".to_string(),
1494 refresh_token: Some("refresh".to_string()),
1495 expires_at: 0,
1496 scopes: None,
1497 provider_data: None,
1498 };
1499 assert!(oauth_cred_expired.is_expired());
1500 }
1501
1502 #[test]
1503 fn test_auth_credential_needs_refresh() {
1504 let future_time = now_secs() + 120; let oauth_cred = AuthCredential::OAuth {
1508 access_token: "token".to_string(),
1509 refresh_token: Some("refresh".to_string()),
1510 expires_at: future_time,
1511 scopes: None,
1512 provider_data: None,
1513 };
1514 assert!(!oauth_cred.needs_refresh());
1515
1516 let soon = now_secs() + 30;
1518 let oauth_soon = AuthCredential::OAuth {
1519 access_token: "token".to_string(),
1520 refresh_token: Some("refresh".to_string()),
1521 expires_at: soon,
1522 scopes: None,
1523 provider_data: None,
1524 };
1525 assert!(oauth_soon.needs_refresh());
1526
1527 let no_refresh = AuthCredential::OAuth {
1529 access_token: "token".to_string(),
1530 refresh_token: None,
1531 expires_at: future_time,
1532 scopes: None,
1533 provider_data: None,
1534 };
1535 assert!(!no_refresh.needs_refresh());
1536
1537 let api_key_cred = AuthCredential::ApiKey {
1539 key: "test".to_string(),
1540 };
1541 assert!(!api_key_cred.needs_refresh());
1542 }
1543
1544 #[test]
1545 fn test_auth_credential_access_token() {
1546 let future_time = now_secs() + 3600;
1547
1548 let oauth_cred = AuthCredential::OAuth {
1549 access_token: "valid_token".to_string(),
1550 refresh_token: Some("refresh".to_string()),
1551 expires_at: future_time,
1552 scopes: None,
1553 provider_data: None,
1554 };
1555 assert_eq!(oauth_cred.access_token(), Some("valid_token"));
1556
1557 let expired_cred = AuthCredential::OAuth {
1559 access_token: "expired_token".to_string(),
1560 refresh_token: Some("refresh".to_string()),
1561 expires_at: 0,
1562 scopes: None,
1563 provider_data: None,
1564 };
1565 assert!(expired_cred.access_token().is_none());
1566
1567 let api_key_cred = AuthCredential::ApiKey {
1569 key: "api_key_token".to_string(),
1570 };
1571 assert!(api_key_cred.access_token().is_none());
1572 }
1573
1574 #[test]
1575 fn test_get_oauth_credential() {
1576 let storage = AuthStorage::in_memory();
1577 storage.set_oauth(
1578 "provider",
1579 "access".to_string(),
1580 Some("refresh".to_string()),
1581 u64::MAX,
1582 );
1583
1584 let cred = storage.get_oauth_credential("provider");
1585 assert!(cred.is_some());
1586 assert!(matches!(cred.unwrap(), AuthCredential::OAuth { .. }));
1587 }
1588
1589 #[test]
1590 fn test_has_oauth_with_refresh() {
1591 let storage = AuthStorage::in_memory();
1592
1593 storage.set_oauth(
1595 "with_refresh",
1596 "access".to_string(),
1597 Some("refresh".to_string()),
1598 u64::MAX,
1599 );
1600 assert!(storage.has_oauth_with_refresh("with_refresh"));
1601
1602 storage.set_oauth("without_refresh", "access".to_string(), None, u64::MAX);
1604 assert!(!storage.has_oauth_with_refresh("without_refresh"));
1605
1606 storage.set_api_key("apikey_provider", "key".to_string());
1608 assert!(!storage.has_oauth_with_refresh("apikey_provider"));
1609 }
1610
1611 #[test]
1612 fn test_set_oauth_full() {
1613 let storage = AuthStorage::in_memory();
1614 storage.set_oauth_full(
1615 "provider",
1616 "access_token".to_string(),
1617 Some("refresh_token".to_string()),
1618 3600,
1619 Some("read write".to_string()),
1620 Some(serde_json::json!({"extra": "data"})),
1621 );
1622
1623 let cred = storage.get_oauth_credential("provider");
1624 assert!(cred.is_some());
1625 if let AuthCredential::OAuth {
1626 scopes,
1627 provider_data,
1628 ..
1629 } = cred.unwrap()
1630 {
1631 assert_eq!(scopes, Some("read write".to_string()));
1632 assert!(provider_data.is_some());
1633 } else {
1634 panic!("Expected OAuth credential");
1635 }
1636 }
1637
1638 #[test]
1639 fn test_session_token() {
1640 let storage = AuthStorage::in_memory();
1641 storage.set_session(
1642 "browser",
1643 "session-token-123".to_string(),
1644 0, Some(serde_json::json!({"user": "test"})),
1646 );
1647
1648 assert!(storage.has("browser"));
1649 assert_eq!(
1650 storage.get_api_key("browser"),
1651 Some("session-token-123".to_string())
1652 );
1653
1654 let cred = storage.get("browser").unwrap();
1655 assert!(matches!(cred, AuthCredential::Session { .. }));
1656 assert!(cred.access_token().is_some());
1657 }
1658
1659 #[test]
1660 fn test_session_token_expired() {
1661 let storage = AuthStorage::in_memory();
1662 storage.set_session("browser", "session-token".to_string(), 1, None);
1663
1664 assert!(storage.get_api_key("browser").is_none());
1666 }
1667
1668 #[test]
1669 fn test_credential_validation() {
1670 let valid = AuthCredential::ApiKey {
1672 key: "sk-valid".to_string(),
1673 };
1674 assert!(valid.validate().is_ok());
1675
1676 let empty = AuthCredential::ApiKey {
1678 key: "".to_string(),
1679 };
1680 assert!(empty.validate().is_err());
1681
1682 let placeholder = AuthCredential::ApiKey {
1684 key: "your-api-key-here".to_string(),
1685 };
1686 assert!(placeholder.validate().is_err());
1687
1688 let valid_oauth = AuthCredential::OAuth {
1690 access_token: "token".to_string(),
1691 refresh_token: None,
1692 expires_at: now_secs() + 3600,
1693 scopes: None,
1694 provider_data: None,
1695 };
1696 assert!(valid_oauth.validate().is_ok());
1697
1698 let invalid_oauth = AuthCredential::OAuth {
1700 access_token: "".to_string(),
1701 refresh_token: None,
1702 expires_at: 1000,
1703 scopes: None,
1704 provider_data: None,
1705 };
1706 assert!(invalid_oauth.validate().is_err());
1707 }
1708
1709 #[test]
1710 fn test_validate_all() {
1711 let storage = AuthStorage::in_memory();
1712 storage.set_api_key("valid", "sk-good".to_string());
1713 storage.set_api_key("empty", "".to_string());
1714
1715 let errors = storage.validate_all();
1716 assert_eq!(errors.len(), 1);
1717 assert_eq!(errors[0].0, "empty");
1718 }
1719
1720 #[test]
1721 fn test_update_oauth_tokens() {
1722 let storage = AuthStorage::in_memory();
1723 storage.set_oauth(
1724 "provider",
1725 "old-access".to_string(),
1726 Some("old-refresh".to_string()),
1727 now_secs() + 3600,
1728 );
1729
1730 storage
1731 .update_oauth_tokens(
1732 "provider",
1733 "new-access".to_string(),
1734 Some("new-refresh".to_string()),
1735 now_secs() + 7200,
1736 )
1737 .unwrap();
1738
1739 let key = storage.get_api_key("provider");
1740 assert_eq!(key, Some("new-access".to_string()));
1741 }
1742
1743 #[test]
1744 fn test_update_oauth_tokens_wrong_type() {
1745 let storage = AuthStorage::in_memory();
1746 storage.set_api_key("provider", "key".to_string());
1747
1748 let result = storage.update_oauth_tokens(
1749 "provider",
1750 "new-access".to_string(),
1751 None,
1752 now_secs() + 3600,
1753 );
1754 assert!(result.is_err());
1755 }
1756
1757 #[test]
1758 fn test_migrate_provider() {
1759 let storage = AuthStorage::in_memory();
1760 storage.set_api_key("old-provider", "key123".to_string());
1761 storage
1762 .migrate_provider("old-provider", "new-provider")
1763 .unwrap();
1764
1765 assert!(!storage.has("old-provider"));
1766 assert!(storage.has("new-provider"));
1767 assert_eq!(
1768 storage.get_api_key("new-provider"),
1769 Some("key123".to_string())
1770 );
1771 }
1772
1773 #[test]
1774 fn test_migrate_provider_not_found() {
1775 let storage = AuthStorage::in_memory();
1776 let result = storage.migrate_provider("nonexistent", "target");
1777 assert!(result.is_err());
1778 }
1779
1780 #[test]
1781 fn test_error_draining() {
1782 let storage = AuthStorage::in_memory();
1783 let errors = storage.drain_errors();
1784 assert!(errors.is_empty());
1785 }
1786
1787 #[test]
1788 fn test_fallback_resolver() {
1789 let storage = AuthStorage::in_memory();
1790 storage.set_fallback_resolver(Arc::new(FnFallbackResolver::new(Box::new(|provider| {
1791 if provider == "custom" {
1792 Some("custom-key-from-config".to_string())
1793 } else {
1794 None
1795 }
1796 }))));
1797
1798 assert_eq!(
1799 storage.get_api_key("custom"),
1800 Some("custom-key-from-config".to_string())
1801 );
1802 assert!(storage.get_api_key("unknown").is_none());
1803
1804 storage.clear_fallback_resolver();
1806 assert!(storage.get_api_key("custom").is_none());
1807 }
1808
1809 #[test]
1810 fn test_get_api_key_with_options() {
1811 let storage = AuthStorage::in_memory();
1812 storage.set_fallback_resolver(Arc::new(FnFallbackResolver::new(Box::new(|_| {
1813 Some("fallback-key".to_string())
1814 }))));
1815
1816 assert_eq!(
1818 storage.get_api_key_with_options("test", true),
1819 Some("fallback-key".to_string())
1820 );
1821
1822 assert!(storage.get_api_key_with_options("test", false).is_none());
1824 }
1825
1826 #[test]
1827 fn test_configured_providers() {
1828 let storage = AuthStorage::in_memory();
1829 storage.set_api_key("openai", "key".to_string());
1830 storage.set_api_key("anthropic", "key".to_string());
1831
1832 let providers = storage.configured_providers();
1833 assert!(providers.len() >= 2);
1834 let mut sorted = providers.clone();
1836 sorted.sort();
1837 assert_eq!(providers, sorted);
1838 }
1839
1840 #[test]
1841 fn test_has_multiple_providers() {
1842 let storage = AuthStorage::in_memory();
1843 assert!(!storage.has_multiple_providers());
1844
1845 storage.set_api_key("openai", "key1".to_string());
1846 assert!(!storage.has_multiple_providers());
1847
1848 storage.set_api_key("anthropic", "key2".to_string());
1849 assert!(storage.has_multiple_providers());
1850 }
1851
1852 #[test]
1853 fn test_set_and_get_credential() {
1854 let storage = AuthStorage::in_memory();
1855 let cred = AuthCredential::Session {
1856 token: "abc".to_string(),
1857 expires_at: 0,
1858 metadata: None,
1859 };
1860 storage.set("custom", cred);
1861 let retrieved = storage.get("custom");
1862 assert!(retrieved.is_some());
1863 assert!(matches!(retrieved.unwrap(), AuthCredential::Session { .. }));
1864 }
1865
1866 #[test]
1867 fn test_credential_type_name() {
1868 assert_eq!(
1869 AuthCredential::ApiKey {
1870 key: "k".to_string()
1871 }
1872 .type_name(),
1873 "api_key"
1874 );
1875 assert_eq!(
1876 AuthCredential::OAuth {
1877 access_token: "t".to_string(),
1878 refresh_token: None,
1879 expires_at: 0,
1880 scopes: None,
1881 provider_data: None,
1882 }
1883 .type_name(),
1884 "oauth"
1885 );
1886 assert_eq!(
1887 AuthCredential::Session {
1888 token: "t".to_string(),
1889 expires_at: 0,
1890 metadata: None,
1891 }
1892 .type_name(),
1893 "session"
1894 );
1895 }
1896
1897 #[test]
1900 fn debug_impl_masks_api_key() {
1901 let cred = AuthCredential::ApiKey {
1902 key: "sk-super-secret-12345".to_string(),
1903 };
1904 let dbg = format!("{cred:?}");
1905 assert!(
1906 !dbg.contains("sk-super-secret-12345"),
1907 "Debug must not leak the API key, got: {dbg}"
1908 );
1909 assert!(
1910 dbg.contains("<redacted>"),
1911 "Debug must show <redacted> placeholder, got: {dbg}"
1912 );
1913 }
1914
1915 #[test]
1916 fn debug_impl_masks_oauth_tokens() {
1917 let cred = AuthCredential::OAuth {
1918 access_token: "ya29.access-token-value".to_string(),
1919 refresh_token: Some("ya29.refresh-token-value".to_string()),
1920 expires_at: 9999999999,
1921 scopes: None,
1922 provider_data: None,
1923 };
1924 let dbg = format!("{cred:?}");
1925 assert!(
1926 !dbg.contains("access-token-value"),
1927 "Debug must not leak the access token, got: {dbg}"
1928 );
1929 assert!(
1930 !dbg.contains("refresh-token-value"),
1931 "Debug must not leak the refresh token, got: {dbg}"
1932 );
1933 assert!(
1935 dbg.contains("9999999999"),
1936 "Debug must still show expires_at for diagnostics, got: {dbg}"
1937 );
1938 }
1939
1940 #[test]
1941 fn debug_impl_masks_session_token() {
1942 let cred = AuthCredential::Session {
1943 token: "sess-abc-def-ghi".to_string(),
1944 expires_at: 0,
1945 metadata: None,
1946 };
1947 let dbg = format!("{cred:?}");
1948 assert!(
1949 !dbg.contains("sess-abc-def-ghi"),
1950 "Debug must not leak the session token, got: {dbg}"
1951 );
1952 assert!(
1953 dbg.contains("<redacted>"),
1954 "Debug must show <redacted> placeholder, got: {dbg}"
1955 );
1956 }
1957}