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