Skip to main content

oxi/store/
auth_storage.rs

1//! Authentication storage for API keys, OAuth tokens, and session tokens.
2//!
3//! Provides secure storage and retrieval of authentication credentials,
4//! with OS keyring integration and fallback to encrypted file storage.
5//! Supports multi-provider auth, credential validation, and session tokens.
6
7use parking_lot::RwLock;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::PathBuf;
11use std::sync::{Arc, OnceLock};
12
13// ============================================================================
14// Credential Types
15// ============================================================================
16
17/// Authentication credential
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(tag = "type", rename_all = "snake_case")]
20pub enum AuthCredential {
21    /// API key credential
22    ApiKey {
23        /// The API key string.
24        key: String,
25    },
26    /// OAuth credential with token management
27    OAuth {
28        /// access_token.
29        access_token: String,
30        /// refresh_token.
31        refresh_token: Option<String>,
32        /// expires_at.
33        expires_at: u64,
34        /// Scopes granted (space-separated)
35        #[serde(default)]
36        scopes: Option<String>,
37        /// Provider-specific data (JSON for extensibility)
38        #[serde(default)]
39        provider_data: Option<serde_json::Value>,
40    },
41    /// Session token credential (e.g. from browser-based login)
42    Session {
43        /// token.
44        token: String,
45        /// When the session expires (unix timestamp, 0 = never)
46        #[serde(default)]
47        expires_at: u64,
48        /// Session metadata (user info, etc.)
49        #[serde(default)]
50        metadata: Option<serde_json::Value>,
51    },
52}
53
54impl AuthCredential {
55    /// Check if the credential is expired
56    pub fn is_expired(&self) -> bool {
57        match self {
58            AuthCredential::OAuth { expires_at, .. } => {
59                let now = now_secs();
60                *expires_at < now
61            }
62            AuthCredential::Session { expires_at, .. } => {
63                if *expires_at == 0 {
64                    return false; // never expires
65                }
66                *expires_at <= now_secs()
67            }
68            AuthCredential::ApiKey { .. } => false,
69        }
70    }
71
72    /// Check if the token needs refresh (within 60 seconds of expiration)
73    pub fn needs_refresh(&self) -> bool {
74        match self {
75            AuthCredential::OAuth {
76                expires_at,
77                refresh_token,
78                ..
79            } => {
80                let now = now_secs();
81                refresh_token.is_some() && *expires_at <= now + 60
82            }
83            AuthCredential::Session { .. } => false,
84            AuthCredential::ApiKey { .. } => false,
85        }
86    }
87
88    /// Get the access token if valid (not expired)
89    pub fn access_token(&self) -> Option<&str> {
90        match self {
91            AuthCredential::OAuth { access_token, .. } if !self.is_expired() => Some(access_token),
92            AuthCredential::Session { token, .. } if !self.is_expired() => Some(token),
93            _ => None,
94        }
95    }
96
97    /// Get the credential type name
98    pub fn type_name(&self) -> &'static str {
99        match self {
100            AuthCredential::ApiKey { .. } => "api_key",
101            AuthCredential::OAuth { .. } => "oauth",
102            AuthCredential::Session { .. } => "session",
103        }
104    }
105
106    /// Validate the credential structure
107    pub fn validate(&self) -> Result<(), CredentialValidationError> {
108        match self {
109            AuthCredential::ApiKey { key } => {
110                if key.is_empty() {
111                    return Err(CredentialValidationError::EmptyField("key".to_string()));
112                }
113                // Check for common placeholder values
114                if key == "your-api-key-here" || key == "xxx" {
115                    return Err(CredentialValidationError::PlaceholderValue(key.clone()));
116                }
117                Ok(())
118            }
119            AuthCredential::OAuth {
120                access_token,
121                expires_at,
122                ..
123            } => {
124                if access_token.is_empty() {
125                    return Err(CredentialValidationError::EmptyField(
126                        "access_token".to_string(),
127                    ));
128                }
129                if *expires_at == 0 {
130                    return Err(CredentialValidationError::InvalidExpiry);
131                }
132                Ok(())
133            }
134            AuthCredential::Session { token, .. } => {
135                if token.is_empty() {
136                    return Err(CredentialValidationError::EmptyField("token".to_string()));
137                }
138                Ok(())
139            }
140        }
141    }
142}
143
144/// Credential validation error
145#[derive(Debug, Clone, thiserror::Error)]
146pub enum CredentialValidationError {
147    #[error("Field '{0}' must not be empty")]
148    /// empty field variant.
149    EmptyField(String),
150    #[error("Placeholder value detected: '{0}'")]
151    /// placeholder value variant.
152    PlaceholderValue(String),
153    #[error("Invalid expiry timestamp")]
154    /// invalid expiry variant.
155    InvalidExpiry,
156}
157
158// ============================================================================
159// Auth Status
160// ============================================================================
161
162/// Authentication status
163#[derive(Debug, Clone)]
164pub struct AuthStatus {
165    /// Whether auth is configured
166    pub configured: bool,
167    /// Source of the auth (stored, runtime, environment, fallback)
168    pub source: Option<String>,
169    /// Label for display
170    pub label: Option<String>,
171}
172
173impl std::fmt::Display for AuthStatus {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        match (&self.source, &self.label) {
176            (Some(source), Some(label)) => write!(f, "{} ({})", source, label),
177            (Some(source), None) => write!(f, "{}", source),
178            (None, Some(label)) => write!(f, "{}", label),
179            (None, None) => write!(f, "not configured"),
180        }
181    }
182}
183
184// ============================================================================
185// Auth Errors
186// ============================================================================
187
188/// Result of an auth operation
189pub type AuthResult<T> = Result<T, AuthError>;
190
191/// Authentication errors
192#[derive(Debug, Clone, thiserror::Error)]
193pub enum AuthError {
194    #[error("Failed to read auth storage: {0}")]
195    /// read error variant.
196    ReadError(String),
197    #[error("Failed to write auth storage: {0}")]
198    /// write error variant.
199    WriteError(String),
200    #[error("Credential not found: {0}")]
201    /// not found variant.
202    NotFound(String),
203    #[error("Invalid credential format: {0}")]
204    /// invalid format variant.
205    InvalidFormat(String),
206    #[error("Keyring error: {0}")]
207    /// keyring error variant.
208    KeyringError(String),
209    #[error("Credential validation failed: {0}")]
210    /// validation failed variant.
211    ValidationFailed(String),
212}
213
214// ============================================================================
215// Storage Backend Trait
216// ============================================================================
217
218/// Storage backend trait
219pub trait AuthStorageBackend: Send + Sync {
220    /// Read stored data
221    fn read(&self) -> AuthResult<Option<String>>;
222    /// Write data
223    fn write(&self, data: &str) -> AuthResult<()>;
224    /// Delete stored data
225    fn delete(&self) -> AuthResult<()>;
226}
227
228// ============================================================================
229// File Backend
230// ============================================================================
231
232/// File-based auth storage backend
233pub struct FileAuthStorage {
234    path: PathBuf,
235    cache: RwLock<Option<String>>,
236}
237
238impl FileAuthStorage {
239    /// Create a new file-based auth storage
240    pub fn new(path: PathBuf) -> Self {
241        Self {
242            path,
243            cache: RwLock::new(None),
244        }
245    }
246
247    /// Get the default auth file path (uses ~/.oxi/auth.json for consistency with settings)
248    pub fn default_path() -> Option<PathBuf> {
249        dirs::home_dir().map(|p| p.join(".oxi").join("auth.json"))
250    }
251
252    /// Get the storage path
253    pub fn path(&self) -> &PathBuf {
254        &self.path
255    }
256}
257
258impl AuthStorageBackend for FileAuthStorage {
259    fn read(&self) -> AuthResult<Option<String>> {
260        if !self.path.exists() {
261            return Ok(None);
262        }
263
264        match std::fs::read_to_string(&self.path) {
265            Ok(content) => {
266                *self.cache.write() = Some(content.clone());
267                Ok(Some(content))
268            }
269            Err(e) => Err(AuthError::ReadError(e.to_string())),
270        }
271    }
272
273    fn write(&self, data: &str) -> AuthResult<()> {
274        // Ensure parent directory exists with restricted permissions
275        if let Some(parent) = self.path.parent() {
276            std::fs::create_dir_all(parent).map_err(|e| AuthError::WriteError(e.to_string()))?;
277
278            #[cfg(unix)]
279            {
280                use std::os::unix::fs::PermissionsExt;
281                let perms = std::fs::Permissions::from_mode(0o700);
282                let _ = std::fs::set_permissions(parent, perms);
283            }
284        }
285
286        // Write the file
287        std::fs::write(&self.path, data).map_err(|e| AuthError::WriteError(e.to_string()))?;
288
289        // Set file permissions to owner-only on Unix
290        #[cfg(unix)]
291        {
292            use std::os::unix::fs::PermissionsExt;
293            let perms = std::fs::Permissions::from_mode(0o600);
294            std::fs::set_permissions(&self.path, perms)
295                .map_err(|e| AuthError::WriteError(e.to_string()))?;
296        }
297
298        *self.cache.write() = Some(data.to_string());
299        Ok(())
300    }
301
302    fn delete(&self) -> AuthResult<()> {
303        if self.path.exists() {
304            std::fs::remove_file(&self.path).map_err(|e| AuthError::WriteError(e.to_string()))?;
305        }
306        *self.cache.write() = None;
307        Ok(())
308    }
309}
310
311// ============================================================================
312// Memory Backend
313// ============================================================================
314
315/// Memory-based auth storage (for testing)
316pub struct MemoryAuthStorage {
317    data: RwLock<HashMap<String, AuthCredential>>,
318}
319
320impl MemoryAuthStorage {
321    /// Create a new memory auth storage
322    pub fn new() -> Self {
323        Self {
324            data: RwLock::new(HashMap::new()),
325        }
326    }
327}
328
329impl Default for MemoryAuthStorage {
330    fn default() -> Self {
331        Self::new()
332    }
333}
334
335impl AuthStorageBackend for MemoryAuthStorage {
336    fn read(&self) -> AuthResult<Option<String>> {
337        // Memory backend doesn't use JSON serialization
338        Ok(None)
339    }
340
341    fn write(&self, _data: &str) -> AuthResult<()> {
342        Ok(())
343    }
344
345    fn delete(&self) -> AuthResult<()> {
346        self.data.write().clear();
347        Ok(())
348    }
349}
350
351// ============================================================================
352// Fallback Resolver
353// ============================================================================
354
355/// Trait for fallback API key resolution (e.g., from models.json config)
356pub trait FallbackResolver: Send + Sync {
357    /// Try to resolve an API key for the given provider
358    fn resolve(&self, provider: &str) -> Option<String>;
359}
360
361/// A simple closure-based fallback resolver
362pub struct FnFallbackResolver {
363    #[allow(clippy::type_complexity)]
364    f: Box<dyn Fn(&str) -> Option<String> + Send + Sync>,
365}
366
367impl FnFallbackResolver {
368    /// Create from a closure
369    #[allow(clippy::type_complexity)]
370    pub fn new(f: Box<dyn Fn(&str) -> Option<String> + Send + Sync>) -> Self {
371        Self { f }
372    }
373}
374
375impl FallbackResolver for FnFallbackResolver {
376    fn resolve(&self, provider: &str) -> Option<String> {
377        (self.f)(provider)
378    }
379}
380
381/// Environment variable fallback resolver.
382///
383/// Uses oxi-ai's `BuiltinProvider` registry to look up the primary and
384/// extra env var names for each provider, then checks `std::env`.
385pub struct EnvVarFallbackResolver;
386
387impl FallbackResolver for EnvVarFallbackResolver {
388    fn resolve(&self, provider: &str) -> Option<String> {
389        // Look up the provider's env key from the builtin registry
390        let builtin = oxi_ai::get_builtin_provider(provider)?;
391        let key = builtin.env_key;
392
393        // Try primary key
394        if let Ok(val) = std::env::var(key)
395            && !val.is_empty()
396        {
397            return Some(val);
398        }
399
400        // Try extra keys
401        for extra in builtin.extra_env_keys {
402            if let Ok(val) = std::env::var(extra)
403                && !val.is_empty()
404            {
405                return Some(val);
406            }
407        }
408
409        None
410    }
411}
412
413// ============================================================================
414// Auth Storage (Main)
415// ============================================================================
416
417/// Main auth storage struct.
418///
419/// Provides multi-layered credential lookup with the following priority:
420/// 1. Runtime override (CLI --api-key)
421/// 2. Stored API key from auth.json
422/// 3. OAuth token from auth.json (with auto-refresh awareness)
423/// 4. Session token from auth.json
424/// 5. Environment variable
425/// 6. Fallback resolver (e.g., custom provider config from models.json)
426pub struct AuthStorage {
427    /// File-based storage backend
428    file_storage: Option<Arc<dyn AuthStorageBackend>>,
429    /// In-memory credential cache
430    credentials: RwLock<HashMap<String, AuthCredential>>,
431    /// Runtime overrides (CLI --api-key)
432    runtime_overrides: RwLock<HashMap<String, String>>,
433    /// Fallback resolver for custom providers
434    fallback_resolver: RwLock<Option<Arc<dyn FallbackResolver>>>,
435    /// Collected errors
436    errors: RwLock<Vec<AuthError>>,
437    /// Whether initial load had an error
438    load_error: RwLock<Option<AuthError>>,
439    /// OnceLock to warn about plaintext storage only once
440    plaintext_warned: OnceLock<()>,
441}
442
443impl AuthStorage {
444    /// Create a new auth storage with default file backend
445    pub fn new() -> Self {
446        let file_storage = FileAuthStorage::default_path()
447            .map(|p| Arc::new(FileAuthStorage::new(p)) as Arc<dyn AuthStorageBackend>);
448
449        let credentials = if let Some(ref storage) = file_storage {
450            match storage.read() {
451                Ok(Some(content)) => serde_json::from_str(&content).unwrap_or_default(),
452                _ => HashMap::new(),
453            }
454        } else {
455            HashMap::new()
456        };
457
458        Self {
459            file_storage,
460            credentials: RwLock::new(credentials),
461            runtime_overrides: RwLock::new(HashMap::new()),
462            fallback_resolver: RwLock::new(None),
463            errors: RwLock::new(Vec::new()),
464            load_error: RwLock::new(None),
465            plaintext_warned: OnceLock::new(),
466        }
467    }
468
469    /// Create with explicit storage backend
470    pub fn with_backend(backend: impl AuthStorageBackend + 'static) -> Self {
471        let credentials = match backend.read() {
472            Ok(Some(content)) => serde_json::from_str(&content).unwrap_or_default(),
473            _ => HashMap::new(),
474        };
475
476        Self {
477            file_storage: Some(Arc::new(backend)),
478            credentials: RwLock::new(credentials),
479            runtime_overrides: RwLock::new(HashMap::new()),
480            fallback_resolver: RwLock::new(None),
481            errors: RwLock::new(Vec::new()),
482            load_error: RwLock::new(None),
483            plaintext_warned: OnceLock::new(),
484        }
485    }
486
487    /// Create a memory-only storage (for testing)
488    pub fn in_memory() -> Self {
489        Self {
490            file_storage: None,
491            credentials: RwLock::new(HashMap::new()),
492            runtime_overrides: RwLock::new(HashMap::new()),
493            fallback_resolver: RwLock::new(None),
494            errors: RwLock::new(Vec::new()),
495            load_error: RwLock::new(None),
496            plaintext_warned: OnceLock::new(),
497        }
498    }
499
500    /// Get the default auth file path
501    pub fn default_path() -> Option<PathBuf> {
502        FileAuthStorage::default_path()
503    }
504
505    // -----------------------------------------------------------------------
506    // Runtime overrides
507    // -----------------------------------------------------------------------
508
509    /// Set a runtime API key override (from CLI --api-key)
510    pub fn set_runtime_key(&self, provider: &str, api_key: String) {
511        self.runtime_overrides
512            .write()
513            .insert(provider.to_string(), api_key);
514    }
515
516    /// Remove a runtime override
517    pub fn remove_runtime_key(&self, provider: &str) {
518        self.runtime_overrides.write().remove(provider);
519    }
520
521    // -----------------------------------------------------------------------
522    // Fallback resolver
523    // -----------------------------------------------------------------------
524
525    /// Set a fallback resolver for API keys not found in auth.json or env vars.
526    /// Used for custom provider keys from models.json.
527    pub fn set_fallback_resolver(&self, resolver: Arc<dyn FallbackResolver>) {
528        *self.fallback_resolver.write() = Some(resolver);
529    }
530
531    /// Clear the fallback resolver
532    pub fn clear_fallback_resolver(&self) {
533        *self.fallback_resolver.write() = None;
534    }
535
536    // -----------------------------------------------------------------------
537    // Credential query
538    // -----------------------------------------------------------------------
539
540    /// Check if a provider has any auth configured
541    pub fn has_auth(&self, provider: &str) -> bool {
542        if self.runtime_overrides.read().contains_key(provider) {
543            return true;
544        }
545        if self.credentials.read().contains_key(provider) {
546            return true;
547        }
548        if let Some(ref resolver) = *self.fallback_resolver.read()
549            && resolver.resolve(provider).is_some()
550        {
551            return true;
552        }
553        false
554    }
555
556    /// Get auth status for a provider (without exposing credentials)
557    pub fn get_status(&self, provider: &str) -> AuthStatus {
558        if self.runtime_overrides.read().contains_key(provider) {
559            return AuthStatus {
560                configured: false,
561                source: Some("runtime".to_string()),
562                label: Some("--api-key".to_string()),
563            };
564        }
565
566        if let Some(cred) = self.credentials.read().get(provider) {
567            return AuthStatus {
568                configured: true,
569                source: Some("stored".to_string()),
570                label: Some(cred.type_name().to_string()),
571            };
572        }
573
574        if let Some(ref resolver) = *self.fallback_resolver.read()
575            && resolver.resolve(provider).is_some()
576        {
577            return AuthStatus {
578                configured: false,
579                source: Some("fallback".to_string()),
580                label: Some("custom provider config".to_string()),
581            };
582        }
583
584        AuthStatus {
585            configured: false,
586            source: None,
587            label: None,
588        }
589    }
590
591    /// Get API key for a provider.
592    ///
593    /// Priority:
594    /// 1. Runtime override (CLI --api-key)
595    /// 2. Stored API key from auth.json
596    /// 3. OAuth token from auth.json (auto-refreshed)
597    /// 4. Session token from auth.json
598    /// 5. Fallback resolver
599    pub fn get_api_key(&self, provider: &str) -> Option<String> {
600        self.get_api_key_with_options(provider, true)
601    }
602
603    /// Get API key with option to include/exclude fallback resolver
604    pub fn get_api_key_with_options(
605        &self,
606        provider: &str,
607        include_fallback: bool,
608    ) -> Option<String> {
609        // 1. Runtime override
610        if let Some(key) = self.runtime_overrides.read().get(provider) {
611            return Some(key.clone());
612        }
613
614        // 2-4. Stored credential
615        if let Some(cred) = self.credentials.read().get(provider) {
616            return match cred {
617                AuthCredential::ApiKey { key } => Some(key.clone()),
618                AuthCredential::OAuth {
619                    access_token,
620                    expires_at,
621                    ..
622                } => {
623                    if *expires_at > now_secs() {
624                        Some(access_token.clone())
625                    } else {
626                        // Token expired - caller should handle refresh
627                        None
628                    }
629                }
630                AuthCredential::Session {
631                    token, expires_at, ..
632                } => {
633                    if *expires_at == 0 || *expires_at > now_secs() {
634                        Some(token.clone())
635                    } else {
636                        None
637                    }
638                }
639            };
640        }
641
642        // 5. Cross-provider alias lookup:
643        //    If the key is stored under a different provider name that shares
644        //    the same env_key (e.g. key stored as "zai-coding-global" but
645        //    looked up as "zai"), check those providers' stored credentials.
646        if let Some(builtin) = oxi_ai::register_builtins::get_builtin_provider(provider) {
647            let env_key = builtin.env_key;
648            let credentials = self.credentials.read();
649            for other in oxi_ai::register_builtins::get_builtin_providers() {
650                if other.name == provider {
651                    continue; // already checked above
652                }
653                if other.env_key == env_key
654                    && let Some(cred) = credentials.get(other.name)
655                {
656                    return match cred {
657                        AuthCredential::ApiKey { key } => Some(key.clone()),
658                        AuthCredential::OAuth {
659                            access_token,
660                            expires_at,
661                            ..
662                        } => {
663                            if *expires_at > now_secs() {
664                                Some(access_token.clone())
665                            } else {
666                                None
667                            }
668                        }
669                        AuthCredential::Session {
670                            token, expires_at, ..
671                        } => {
672                            if *expires_at == 0 || *expires_at > now_secs() {
673                                Some(token.clone())
674                            } else {
675                                None
676                            }
677                        }
678                    };
679                }
680            }
681        }
682
683        // 6. Fallback resolver
684        if include_fallback && let Some(ref resolver) = *self.fallback_resolver.read() {
685            return resolver.resolve(provider);
686        }
687
688        None
689    }
690
691    // -----------------------------------------------------------------------
692    // Credential mutation
693    // -----------------------------------------------------------------------
694
695    /// Set API key for a provider
696    pub fn set_api_key(&self, provider: &str, key: String) {
697        self.credentials
698            .write()
699            .insert(provider.to_string(), AuthCredential::ApiKey { key });
700        if let Err(e) = self.persist() {
701            tracing::warn!("Failed to persist API key for '{}': {}", provider, e);
702        }
703    }
704
705    /// Set OAuth credential for a provider
706    pub fn set_oauth(
707        &self,
708        provider: &str,
709        access_token: String,
710        refresh_token: Option<String>,
711        expires_at: u64,
712    ) {
713        self.set_oauth_full(
714            provider,
715            access_token,
716            refresh_token,
717            expires_at,
718            None,
719            None,
720        );
721    }
722
723    /// Set OAuth credential with full details
724    pub fn set_oauth_full(
725        &self,
726        provider: &str,
727        access_token: String,
728        refresh_token: Option<String>,
729        expires_at: u64,
730        scopes: Option<String>,
731        provider_data: Option<serde_json::Value>,
732    ) {
733        self.credentials.write().insert(
734            provider.to_string(),
735            AuthCredential::OAuth {
736                access_token,
737                refresh_token,
738                expires_at,
739                scopes,
740                provider_data,
741            },
742        );
743        if let Err(e) = self.persist() {
744            tracing::warn!("Failed to persist OAuth token for '{}': {}", provider, e);
745        }
746    }
747
748    /// Set session token for a provider
749    pub fn set_session(
750        &self,
751        provider: &str,
752        token: String,
753        expires_at: u64,
754        metadata: Option<serde_json::Value>,
755    ) {
756        self.credentials.write().insert(
757            provider.to_string(),
758            AuthCredential::Session {
759                token,
760                expires_at,
761                metadata,
762            },
763        );
764        if let Err(e) = self.persist() {
765            tracing::warn!("Failed to persist session for '{}': {}", provider, e);
766        }
767    }
768
769    /// Update an existing OAuth credential (for token refresh)
770    pub fn update_oauth_tokens(
771        &self,
772        provider: &str,
773        new_access_token: String,
774        new_refresh_token: Option<String>,
775        new_expires_at: u64,
776    ) -> AuthResult<()> {
777        let mut creds = self.credentials.write();
778        let cred = creds
779            .get_mut(provider)
780            .ok_or_else(|| AuthError::NotFound(provider.to_string()))?;
781
782        match cred {
783            AuthCredential::OAuth {
784                access_token,
785                refresh_token,
786                expires_at,
787                ..
788            } => {
789                *access_token = new_access_token;
790                *refresh_token = new_refresh_token;
791                *expires_at = new_expires_at;
792            }
793            _ => {
794                return Err(AuthError::InvalidFormat(format!(
795                    "Provider '{}' does not have OAuth credentials",
796                    provider
797                )));
798            }
799        }
800
801        drop(creds);
802        if let Err(e) = self.persist() {
803            tracing::warn!(
804                "Failed to persist OAuth token update for '{}': {}",
805                provider,
806                e
807            );
808        }
809        Ok(())
810    }
811
812    // -----------------------------------------------------------------------
813    // Credential retrieval
814    // -----------------------------------------------------------------------
815
816    /// Get credential for a provider
817    pub fn get(&self, provider: &str) -> Option<AuthCredential> {
818        self.credentials.read().get(provider).cloned()
819    }
820
821    /// Get OAuth credential for a provider (for token refresh)
822    pub fn get_oauth_credential(&self, provider: &str) -> Option<AuthCredential> {
823        self.credentials.read().get(provider).cloned()
824    }
825
826    /// Check if a provider has OAuth credentials that can be refreshed
827    pub fn has_oauth_with_refresh(&self, provider: &str) -> bool {
828        if let Some(cred) = self.credentials.read().get(provider) {
829            matches!(
830                cred,
831                AuthCredential::OAuth {
832                    refresh_token: Some(_),
833                    ..
834                }
835            )
836        } else {
837            false
838        }
839    }
840
841    // -----------------------------------------------------------------------
842    // CRUD operations
843    // -----------------------------------------------------------------------
844
845    /// Set a credential for a provider
846    pub fn set(&self, provider: &str, credential: AuthCredential) {
847        self.credentials
848            .write()
849            .insert(provider.to_string(), credential);
850        if let Err(e) = self.persist() {
851            tracing::warn!("Failed to persist credential for '{}': {}", provider, e);
852        }
853    }
854
855    /// Remove credential for a provider
856    pub fn remove(&self, provider: &str) {
857        self.credentials.write().remove(provider);
858        if let Err(e) = self.persist() {
859            tracing::warn!("Failed to persist after removing '{}': {}", provider, e);
860        }
861    }
862
863    /// List all providers with credentials
864    pub fn list_providers(&self) -> Vec<String> {
865        self.credentials.read().keys().cloned().collect()
866    }
867
868    /// Check if credential exists for provider in storage
869    pub fn has(&self, provider: &str) -> bool {
870        self.credentials.read().contains_key(provider)
871    }
872
873    /// Get all credentials
874    pub fn get_all(&self) -> HashMap<String, AuthCredential> {
875        self.credentials.read().clone()
876    }
877
878    /// Clear all stored credentials
879    pub fn clear(&self) {
880        self.credentials.write().clear();
881        if let Err(e) = self.persist() {
882            tracing::warn!("Failed to persist after clearing credentials: {}", e);
883        }
884    }
885
886    // -----------------------------------------------------------------------
887    // Persistence
888    // -----------------------------------------------------------------------
889
890    /// Reload from disk
891    pub fn reload(&self) {
892        if let Some(ref storage) = self.file_storage {
893            match storage.read() {
894                Ok(Some(content)) => {
895                    if let Ok(creds) = serde_json::from_str(&content) {
896                        *self.credentials.write() = creds;
897                    }
898                    *self.load_error.write() = None;
899                }
900                Ok(None) => {
901                    self.credentials.write().clear();
902                    *self.load_error.write() = None;
903                }
904                Err(e) => {
905                    *self.load_error.write() = Some(e);
906                    self.record_error(AuthError::ReadError(
907                        "Failed to reload auth storage".to_string(),
908                    ));
909                }
910            }
911        }
912    }
913
914    /// Persist to disk
915    #[allow(unexpected_cfgs)]
916    fn persist(&self) -> Result<(), String> {
917        if let Some(ref storage) = self.file_storage {
918            let creds = self.credentials.read();
919            if let Ok(json) = serde_json::to_string_pretty(&*creds) {
920                // F-4 (audit 2026-06-21): the previous warning suggested
921                // enabling a `keyring` feature that is never declared in
922                // `Cargo.toml` (the `keyring_support` module at line 1047
923                // is dead code: `#[cfg(feature = "keyring")]` is never
924                // selected, so `cargo` always builds the `not(feature)`
925                // branch). Replace with an accurate one-shot warning that
926                // names the actual on-disk path and points at the docs.
927                self.plaintext_warned.get_or_init(|| {
928                    tracing::warn!(
929                        "Auth credentials are stored in plaintext at \
930                         ~/.oxi/auth.json (mode 0600). For OS-keyring \
931                         support, see the `oxi-auth-keyring` crate or \
932                         the OXI_KEYRING=1 docs at docs/PORT_GUIDE.md."
933                    );
934                });
935
936                if let Err(e) = storage.write(&json) {
937                    tracing::error!("Failed to persist auth storage: {}", e);
938                    self.record_error(e);
939                    return Err("persist failed".to_string());
940                }
941            }
942        }
943        Ok(())
944    }
945
946    // -----------------------------------------------------------------------
947    // Error tracking
948    // -----------------------------------------------------------------------
949
950    /// Record an error
951    fn record_error(&self, error: AuthError) {
952        self.errors.write().push(error);
953    }
954
955    /// Drain collected errors
956    pub fn drain_errors(&self) -> Vec<AuthError> {
957        let mut errors = self.errors.write();
958        std::mem::take(&mut *errors)
959    }
960
961    /// Get the last load error
962    pub fn load_error(&self) -> Option<AuthError> {
963        self.load_error.read().clone()
964    }
965
966    // -----------------------------------------------------------------------
967    // Validation
968    // -----------------------------------------------------------------------
969
970    /// Validate all stored credentials
971    pub fn validate_all(&self) -> Vec<(String, CredentialValidationError)> {
972        let creds = self.credentials.read();
973        let mut results = Vec::new();
974        for (provider, cred) in creds.iter() {
975            if let Err(e) = cred.validate() {
976                results.push((provider.clone(), e));
977            }
978        }
979        results
980    }
981
982    /// Validate credential for a specific provider
983    pub fn validate(&self, provider: &str) -> Result<(), CredentialValidationError> {
984        let creds = self.credentials.read();
985        let cred = creds.get(provider).ok_or_else(|| {
986            CredentialValidationError::EmptyField(format!(
987                "no credential for provider '{}'",
988                provider
989            ))
990        })?;
991        cred.validate()
992    }
993
994    // -----------------------------------------------------------------------
995    // Multi-provider support
996    // -----------------------------------------------------------------------
997
998    /// Get all configured provider IDs (sorted)
999    pub fn configured_providers(&self) -> Vec<String> {
1000        let mut providers: Vec<String> = self.credentials.read().keys().cloned().collect();
1001        providers.sort();
1002        providers
1003    }
1004
1005    /// Check if multiple providers are configured
1006    pub fn has_multiple_providers(&self) -> bool {
1007        self.credentials.read().len() > 1
1008    }
1009
1010    /// Get the primary provider (first configured, preferring stored over env)
1011    pub fn primary_provider(&self) -> Option<String> {
1012        let creds = self.credentials.read();
1013        creds.keys().next().cloned()
1014    }
1015
1016    /// Migrate credentials from one provider to another
1017    pub fn migrate_provider(&self, from: &str, to: &str) -> AuthResult<()> {
1018        let mut creds = self.credentials.write();
1019        let cred = creds
1020            .remove(from)
1021            .ok_or_else(|| AuthError::NotFound(from.to_string()))?;
1022        creds.insert(to.to_string(), cred);
1023        drop(creds);
1024        let _ = self.persist();
1025        Ok(())
1026    }
1027}
1028
1029impl Default for AuthStorage {
1030    fn default() -> Self {
1031        Self::new()
1032    }
1033}
1034
1035// ============================================================================
1036// SDK AuthProvider port adapter
1037// ============================================================================
1038//
1039// AuthStorage is the CLI's actual credential store: every TUI overlay
1040// (provider_select.rs, slash/builtin/provider.rs, …) writes here via
1041// `shared_auth_storage()`. Registering it DIRECTLY as the SDK `AuthProvider`
1042// port (replacing FileAuthProvider in `build_oxi`) eliminates both the
1043// dual-cache problem (the port IS what overlays write to) and the schema
1044// mismatch (FileAuthProvider's `{version,providers:{name:{api_key}}}`
1045// format can't deserialize AuthStorage's `#[serde(tag="type")]` enum
1046// output — see issue #40 advisor follow-up).
1047//
1048// The sync fast-path is the load-bearing piece: `Oxi::create_provider`
1049// consults it at build / switch_model / refresh_credentials time.
1050
1051impl oxi_sdk::ports::AuthProvider for AuthStorage {
1052    fn get_api_key(
1053        &self,
1054        provider: &str,
1055    ) -> std::pin::Pin<
1056        Box<
1057            dyn std::future::Future<Output = Result<Option<String>, oxi_sdk::SdkError>> + Send + '_,
1058        >,
1059    > {
1060        let result = self.get_api_key(provider);
1061        Box::pin(async move { Ok(result) })
1062    }
1063
1064    /// Sync fast-path — delegates directly to AuthStorage's existing sync
1065    /// `get_api_key`, which already implements the full lookup chain
1066    /// (runtime override → file → OAuth → session → env → fallback).
1067    /// This is the credential source `Oxi::create_provider` consults.
1068    fn get_api_key_sync(&self, provider: &str) -> Result<Option<String>, oxi_sdk::SdkError> {
1069        Ok(self.get_api_key(provider))
1070    }
1071
1072    fn set_api_key(
1073        &self,
1074        provider: &str,
1075        key: &str,
1076    ) -> std::pin::Pin<
1077        Box<dyn std::future::Future<Output = Result<(), oxi_sdk::SdkError>> + Send + '_>,
1078    > {
1079        AuthStorage::set_api_key(self, provider, key.to_string());
1080        Box::pin(async { Ok(()) })
1081    }
1082
1083    fn delete_api_key(
1084        &self,
1085        provider: &str,
1086    ) -> std::pin::Pin<
1087        Box<dyn std::future::Future<Output = Result<(), oxi_sdk::SdkError>> + Send + '_>,
1088    > {
1089        self.credentials.write().remove(provider);
1090        let _ = self.persist();
1091        Box::pin(async { Ok(()) })
1092    }
1093
1094    fn get_oauth(
1095        &self,
1096        provider: &str,
1097    ) -> std::pin::Pin<
1098        Box<
1099            dyn std::future::Future<
1100                    Output = Result<Option<oxi_sdk::ports::OAuthToken>, oxi_sdk::SdkError>,
1101                > + Send
1102                + '_,
1103        >,
1104    > {
1105        let result = self
1106            .get_oauth_credential(provider)
1107            .and_then(|cred| match cred {
1108                AuthCredential::OAuth {
1109                    access_token,
1110                    refresh_token,
1111                    expires_at,
1112                    ..
1113                } => Some(oxi_sdk::ports::OAuthToken {
1114                    access_token,
1115                    refresh_token,
1116                    expires_at: chrono::DateTime::from_timestamp(expires_at as i64, 0),
1117                    token_type: Some("Bearer".to_string()),
1118                    scope: None,
1119                }),
1120                _ => None,
1121            });
1122        Box::pin(async move { Ok(result) })
1123    }
1124
1125    fn set_oauth(
1126        &self,
1127        provider: &str,
1128        token: oxi_sdk::ports::OAuthToken,
1129    ) -> std::pin::Pin<
1130        Box<dyn std::future::Future<Output = Result<(), oxi_sdk::SdkError>> + Send + '_>,
1131    > {
1132        let expires_at = token
1133            .expires_at
1134            .and_then(|dt| dt.timestamp().max(0).try_into().ok())
1135            .unwrap_or(0);
1136        AuthStorage::set_oauth(
1137            self,
1138            provider,
1139            token.access_token,
1140            token.refresh_token,
1141            expires_at,
1142        );
1143        Box::pin(async { Ok(()) })
1144    }
1145
1146    fn list_providers(
1147        &self,
1148    ) -> std::pin::Pin<
1149        Box<dyn std::future::Future<Output = Result<Vec<String>, oxi_sdk::SdkError>> + Send + '_>,
1150    > {
1151        let result = AuthStorage::list_providers(self);
1152        Box::pin(async move { Ok(result) })
1153    }
1154}
1155
1156// ============================================================================
1157// Helper: current unix timestamp
1158// ============================================================================
1159
1160fn now_secs() -> u64 {
1161    std::time::SystemTime::now()
1162        .duration_since(std::time::UNIX_EPOCH)
1163        .map(|d| d.as_secs())
1164        .unwrap_or(0)
1165}
1166
1167// ============================================================================
1168// Keyring Support
1169// ============================================================================
1170//
1171// F-4 (audit 2026-06-21): this module is currently unreachable from the
1172// workspace because the `keyring` feature is not declared in `Cargo.toml`.
1173// It is preserved so a follow-up PR can wire it back in via
1174// `keyring = { version = "2", optional = true }` + `keyring = ["dep:keyring"]`
1175// and add an opt-in env-gated call site in `FileAuthStorage::persist`.
1176// Until that PR lands, the `not(feature = "keyring")` arm always compiles
1177// and the `cfg(feature = "keyring")` arm is dead.
1178
1179/// OS-keyring credential helpers. Currently stubbed because the
1180/// `keyring` cargo feature is not enabled (see F-4 audit note above).
1181#[allow(unexpected_cfgs)]
1182#[deprecated(note = "keyring cargo feature is not wired in Cargo.toml; \
1183    this module is currently a no-op fallback. See docs/PORT_GUIDE.md \
1184    and the F-4 audit note in oxi-cli/src/store/auth_storage.rs.")]
1185pub mod keyring_support {
1186    use super::*;
1187
1188    /// Try to get a secret from the OS keyring
1189    #[cfg(feature = "keyring")]
1190    pub fn get_keyring_secret(service: &str, account: &str) -> Option<String> {
1191        use keyring::Entry;
1192        Entry::new(service, account)
1193            .ok()
1194            .and_then(|entry| entry.get_password().ok())
1195    }
1196
1197    /// Try to set a secret in the OS keyring
1198    #[cfg(feature = "keyring")]
1199    pub fn set_keyring_secret(service: &str, account: &str, secret: &str) -> AuthResult<()> {
1200        use keyring::Entry;
1201        Entry::new(service, account)
1202            .map_err(|e| AuthError::KeyringError(e.to_string()))?
1203            .set_password(secret)
1204            .map_err(|e| AuthError::KeyringError(e.to_string()))
1205    }
1206
1207    /// Try to delete a secret from the OS keyring
1208    #[cfg(feature = "keyring")]
1209    pub fn delete_keyring_secret(service: &str, account: &str) -> AuthResult<()> {
1210        use keyring::Entry;
1211        Entry::new(service, account)
1212            .map_err(|e| AuthError::KeyringError(e.to_string()))?
1213            .delete_credential()
1214            .map_err(|e| AuthError::KeyringError(e.to_string()))
1215    }
1216
1217    // Non-keyring fallbacks
1218    #[cfg(not(feature = "keyring"))]
1219    /// Retrieve a secret from the OS keyring.
1220    ///
1221    /// Returns `None` when the keyring feature is not compiled in.
1222    pub fn get_keyring_secret(_service: &str, _account: &str) -> Option<String> {
1223        None
1224    }
1225
1226    #[cfg(not(feature = "keyring"))]
1227    /// Store a secret in the OS keyring.
1228    ///
1229    /// Returns an error when the keyring feature is not compiled in.
1230    pub fn set_keyring_secret(_service: &str, _account: &str, _secret: &str) -> AuthResult<()> {
1231        Err(AuthError::KeyringError(
1232            "Keyring support not compiled".to_string(),
1233        ))
1234    }
1235
1236    #[cfg(not(feature = "keyring"))]
1237    /// Delete a secret from the OS keyring.
1238    ///
1239    /// Returns an error when the keyring feature is not compiled in.
1240    pub fn delete_keyring_secret(_service: &str, _account: &str) -> AuthResult<()> {
1241        Err(AuthError::KeyringError(
1242            "Keyring support not compiled".to_string(),
1243        ))
1244    }
1245}
1246
1247// ============================================================================
1248// Singleton
1249// ============================================================================
1250
1251/// Get a shared singleton `Arc<AuthStorage>` instance.
1252///
1253/// Avoids creating multiple `AuthStorage::new()` instances that each
1254/// independently read and cache `auth.json`. All callers share the same
1255/// in-memory state through the `Arc`.
1256pub fn shared_auth_storage() -> Arc<AuthStorage> {
1257    static STORAGE: OnceLock<Arc<AuthStorage>> = OnceLock::new();
1258    STORAGE
1259        .get_or_init(|| {
1260            let storage = Arc::new(AuthStorage::new());
1261            // Default fallback: resolve API keys from environment variables
1262            // using the BuiltinProvider registry (env_key + extra_env_keys).
1263            storage.set_fallback_resolver(Arc::new(EnvVarFallbackResolver));
1264            storage
1265        })
1266        .clone()
1267}
1268
1269// ============================================================================
1270// Tests
1271// ============================================================================
1272
1273#[cfg(test)]
1274mod tests {
1275    use super::*;
1276
1277    #[test]
1278    fn test_auth_storage_new() {
1279        let storage = AuthStorage::in_memory();
1280        assert!(!storage.has("anthropic"));
1281    }
1282
1283    #[test]
1284    fn test_set_and_get_api_key() {
1285        let storage = AuthStorage::in_memory();
1286        storage.set_api_key("anthropic", "sk-test123".to_string());
1287        assert!(storage.has("anthropic"));
1288        assert_eq!(
1289            storage.get_api_key("anthropic"),
1290            Some("sk-test123".to_string())
1291        );
1292    }
1293
1294    #[test]
1295    fn test_runtime_override() {
1296        let storage = AuthStorage::in_memory();
1297        storage.set_api_key("anthropic", "stored-key".to_string());
1298        storage.set_runtime_key("anthropic", "runtime-key".to_string());
1299
1300        // Runtime key should take priority
1301        assert_eq!(
1302            storage.get_api_key("anthropic"),
1303            Some("runtime-key".to_string())
1304        );
1305    }
1306
1307    #[test]
1308    fn test_remove_credential() {
1309        let storage = AuthStorage::in_memory();
1310        storage.set_api_key("anthropic", "sk-test123".to_string());
1311        assert!(storage.has("anthropic"));
1312
1313        storage.remove("anthropic");
1314        assert!(!storage.has("anthropic"));
1315    }
1316
1317    #[test]
1318    fn test_auth_status() {
1319        let storage = AuthStorage::in_memory();
1320        storage.set_api_key("anthropic", "sk-test123".to_string());
1321
1322        let status = storage.get_status("anthropic");
1323        assert!(status.configured);
1324        assert_eq!(status.source, Some("stored".to_string()));
1325        assert_eq!(status.label, Some("api_key".to_string()));
1326    }
1327
1328    #[test]
1329    fn test_auth_status_display() {
1330        let status = AuthStatus {
1331            configured: true,
1332            source: Some("stored".to_string()),
1333            label: Some("api_key".to_string()),
1334        };
1335        let display = format!("{}", status);
1336        assert_eq!(display, "stored (api_key)");
1337
1338        let no_config = AuthStatus {
1339            configured: false,
1340            source: None,
1341            label: None,
1342        };
1343        assert_eq!(format!("{}", no_config), "not configured");
1344    }
1345
1346    #[test]
1347    fn test_list_providers() {
1348        let storage = AuthStorage::in_memory();
1349        storage.set_api_key("anthropic", "key1".to_string());
1350        storage.set_api_key("openai", "key2".to_string());
1351
1352        let providers = storage.list_providers();
1353        assert!(providers.contains(&"anthropic".to_string()));
1354        assert!(providers.contains(&"openai".to_string()));
1355    }
1356
1357    #[test]
1358    fn test_oauth_credential() {
1359        let storage = AuthStorage::in_memory();
1360        storage.set_oauth(
1361            "provider",
1362            "access123".to_string(),
1363            Some("refresh456".to_string()),
1364            u64::MAX,
1365        );
1366
1367        assert!(storage.has("provider"));
1368        assert_eq!(
1369            storage.get_api_key("provider"),
1370            Some("access123".to_string())
1371        );
1372    }
1373
1374    #[test]
1375    fn test_expired_oauth_token() {
1376        let storage = AuthStorage::in_memory();
1377        // Set token that expired in the past
1378        storage.set_oauth("provider", "access123".to_string(), None, 0);
1379
1380        // Token should be treated as expired
1381        let key = storage.get_api_key("provider");
1382        assert!(key.is_none());
1383    }
1384
1385    #[test]
1386    fn test_get_all_credentials() {
1387        let storage = AuthStorage::in_memory();
1388        storage.set_api_key("anthropic", "key1".to_string());
1389        storage.set_api_key("openai", "key2".to_string());
1390
1391        let all = storage.get_all();
1392        assert_eq!(all.len(), 2);
1393    }
1394
1395    #[test]
1396    fn test_clear() {
1397        let storage = AuthStorage::in_memory();
1398        storage.set_api_key("anthropic", "key".to_string());
1399        assert!(storage.has("anthropic"));
1400
1401        storage.clear();
1402        assert!(!storage.has("anthropic"));
1403    }
1404
1405    #[test]
1406    fn test_remove_runtime_key() {
1407        let storage = AuthStorage::in_memory();
1408        storage.set_api_key("anthropic", "stored".to_string());
1409        storage.set_runtime_key("anthropic", "runtime".to_string());
1410
1411        assert_eq!(
1412            storage.get_api_key("anthropic"),
1413            Some("runtime".to_string())
1414        );
1415
1416        storage.remove_runtime_key("anthropic");
1417        assert_eq!(storage.get_api_key("anthropic"), Some("stored".to_string()));
1418    }
1419
1420    #[test]
1421    fn test_auth_credential_is_expired() {
1422        // API key never expires
1423        let api_key_cred = AuthCredential::ApiKey {
1424            key: "test".to_string(),
1425        };
1426        assert!(!api_key_cred.is_expired());
1427
1428        // OAuth token that expires in the future
1429        let future_time = now_secs() + 3600;
1430        let oauth_cred = AuthCredential::OAuth {
1431            access_token: "token".to_string(),
1432            refresh_token: Some("refresh".to_string()),
1433            expires_at: future_time,
1434            scopes: None,
1435            provider_data: None,
1436        };
1437        assert!(!oauth_cred.is_expired());
1438
1439        // OAuth token that expired in the past
1440        let oauth_cred_expired = AuthCredential::OAuth {
1441            access_token: "token".to_string(),
1442            refresh_token: Some("refresh".to_string()),
1443            expires_at: 0,
1444            scopes: None,
1445            provider_data: None,
1446        };
1447        assert!(oauth_cred_expired.is_expired());
1448    }
1449
1450    #[test]
1451    fn test_auth_credential_needs_refresh() {
1452        let future_time = now_secs() + 120; // 2 minutes from now
1453
1454        // Has refresh token, will expire soon - not yet within 60s
1455        let oauth_cred = AuthCredential::OAuth {
1456            access_token: "token".to_string(),
1457            refresh_token: Some("refresh".to_string()),
1458            expires_at: future_time,
1459            scopes: None,
1460            provider_data: None,
1461        };
1462        assert!(!oauth_cred.needs_refresh());
1463
1464        // Within 60 seconds
1465        let soon = now_secs() + 30;
1466        let oauth_soon = AuthCredential::OAuth {
1467            access_token: "token".to_string(),
1468            refresh_token: Some("refresh".to_string()),
1469            expires_at: soon,
1470            scopes: None,
1471            provider_data: None,
1472        };
1473        assert!(oauth_soon.needs_refresh());
1474
1475        // No refresh token - doesn't need refresh
1476        let no_refresh = AuthCredential::OAuth {
1477            access_token: "token".to_string(),
1478            refresh_token: None,
1479            expires_at: future_time,
1480            scopes: None,
1481            provider_data: None,
1482        };
1483        assert!(!no_refresh.needs_refresh());
1484
1485        // API key never needs refresh
1486        let api_key_cred = AuthCredential::ApiKey {
1487            key: "test".to_string(),
1488        };
1489        assert!(!api_key_cred.needs_refresh());
1490    }
1491
1492    #[test]
1493    fn test_auth_credential_access_token() {
1494        let future_time = now_secs() + 3600;
1495
1496        let oauth_cred = AuthCredential::OAuth {
1497            access_token: "valid_token".to_string(),
1498            refresh_token: Some("refresh".to_string()),
1499            expires_at: future_time,
1500            scopes: None,
1501            provider_data: None,
1502        };
1503        assert_eq!(oauth_cred.access_token(), Some("valid_token"));
1504
1505        // Expired token
1506        let expired_cred = AuthCredential::OAuth {
1507            access_token: "expired_token".to_string(),
1508            refresh_token: Some("refresh".to_string()),
1509            expires_at: 0,
1510            scopes: None,
1511            provider_data: None,
1512        };
1513        assert!(expired_cred.access_token().is_none());
1514
1515        // API key returns None via access_token
1516        let api_key_cred = AuthCredential::ApiKey {
1517            key: "api_key_token".to_string(),
1518        };
1519        assert!(api_key_cred.access_token().is_none());
1520    }
1521
1522    #[test]
1523    fn test_get_oauth_credential() {
1524        let storage = AuthStorage::in_memory();
1525        storage.set_oauth(
1526            "provider",
1527            "access".to_string(),
1528            Some("refresh".to_string()),
1529            u64::MAX,
1530        );
1531
1532        let cred = storage.get_oauth_credential("provider");
1533        assert!(cred.is_some());
1534        assert!(matches!(cred.unwrap(), AuthCredential::OAuth { .. }));
1535    }
1536
1537    #[test]
1538    fn test_has_oauth_with_refresh() {
1539        let storage = AuthStorage::in_memory();
1540
1541        // With refresh token
1542        storage.set_oauth(
1543            "with_refresh",
1544            "access".to_string(),
1545            Some("refresh".to_string()),
1546            u64::MAX,
1547        );
1548        assert!(storage.has_oauth_with_refresh("with_refresh"));
1549
1550        // Without refresh token
1551        storage.set_oauth("without_refresh", "access".to_string(), None, u64::MAX);
1552        assert!(!storage.has_oauth_with_refresh("without_refresh"));
1553
1554        // API key provider
1555        storage.set_api_key("apikey_provider", "key".to_string());
1556        assert!(!storage.has_oauth_with_refresh("apikey_provider"));
1557    }
1558
1559    #[test]
1560    fn test_set_oauth_full() {
1561        let storage = AuthStorage::in_memory();
1562        storage.set_oauth_full(
1563            "provider",
1564            "access_token".to_string(),
1565            Some("refresh_token".to_string()),
1566            3600,
1567            Some("read write".to_string()),
1568            Some(serde_json::json!({"extra": "data"})),
1569        );
1570
1571        let cred = storage.get_oauth_credential("provider");
1572        assert!(cred.is_some());
1573        if let AuthCredential::OAuth {
1574            scopes,
1575            provider_data,
1576            ..
1577        } = cred.unwrap()
1578        {
1579            assert_eq!(scopes, Some("read write".to_string()));
1580            assert!(provider_data.is_some());
1581        } else {
1582            panic!("Expected OAuth credential");
1583        }
1584    }
1585
1586    #[test]
1587    fn test_session_token() {
1588        let storage = AuthStorage::in_memory();
1589        storage.set_session(
1590            "browser",
1591            "session-token-123".to_string(),
1592            0, // never expires
1593            Some(serde_json::json!({"user": "test"})),
1594        );
1595
1596        assert!(storage.has("browser"));
1597        assert_eq!(
1598            storage.get_api_key("browser"),
1599            Some("session-token-123".to_string())
1600        );
1601
1602        let cred = storage.get("browser").unwrap();
1603        assert!(matches!(cred, AuthCredential::Session { .. }));
1604        assert!(cred.access_token().is_some());
1605    }
1606
1607    #[test]
1608    fn test_session_token_expired() {
1609        let storage = AuthStorage::in_memory();
1610        storage.set_session("browser", "session-token".to_string(), 1, None);
1611
1612        // Token expired (timestamp 1 is in the past)
1613        assert!(storage.get_api_key("browser").is_none());
1614    }
1615
1616    #[test]
1617    fn test_credential_validation() {
1618        // Valid API key
1619        let valid = AuthCredential::ApiKey {
1620            key: "sk-valid".to_string(),
1621        };
1622        assert!(valid.validate().is_ok());
1623
1624        // Empty API key
1625        let empty = AuthCredential::ApiKey {
1626            key: "".to_string(),
1627        };
1628        assert!(empty.validate().is_err());
1629
1630        // Placeholder
1631        let placeholder = AuthCredential::ApiKey {
1632            key: "your-api-key-here".to_string(),
1633        };
1634        assert!(placeholder.validate().is_err());
1635
1636        // Valid OAuth
1637        let valid_oauth = AuthCredential::OAuth {
1638            access_token: "token".to_string(),
1639            refresh_token: None,
1640            expires_at: now_secs() + 3600,
1641            scopes: None,
1642            provider_data: None,
1643        };
1644        assert!(valid_oauth.validate().is_ok());
1645
1646        // Invalid OAuth (empty token)
1647        let invalid_oauth = AuthCredential::OAuth {
1648            access_token: "".to_string(),
1649            refresh_token: None,
1650            expires_at: 1000,
1651            scopes: None,
1652            provider_data: None,
1653        };
1654        assert!(invalid_oauth.validate().is_err());
1655    }
1656
1657    #[test]
1658    fn test_validate_all() {
1659        let storage = AuthStorage::in_memory();
1660        storage.set_api_key("valid", "sk-good".to_string());
1661        storage.set_api_key("empty", "".to_string());
1662
1663        let errors = storage.validate_all();
1664        assert_eq!(errors.len(), 1);
1665        assert_eq!(errors[0].0, "empty");
1666    }
1667
1668    #[test]
1669    fn test_update_oauth_tokens() {
1670        let storage = AuthStorage::in_memory();
1671        storage.set_oauth(
1672            "provider",
1673            "old-access".to_string(),
1674            Some("old-refresh".to_string()),
1675            now_secs() + 3600,
1676        );
1677
1678        storage
1679            .update_oauth_tokens(
1680                "provider",
1681                "new-access".to_string(),
1682                Some("new-refresh".to_string()),
1683                now_secs() + 7200,
1684            )
1685            .unwrap();
1686
1687        let key = storage.get_api_key("provider");
1688        assert_eq!(key, Some("new-access".to_string()));
1689    }
1690
1691    #[test]
1692    fn test_update_oauth_tokens_wrong_type() {
1693        let storage = AuthStorage::in_memory();
1694        storage.set_api_key("provider", "key".to_string());
1695
1696        let result = storage.update_oauth_tokens(
1697            "provider",
1698            "new-access".to_string(),
1699            None,
1700            now_secs() + 3600,
1701        );
1702        assert!(result.is_err());
1703    }
1704
1705    #[test]
1706    fn test_migrate_provider() {
1707        let storage = AuthStorage::in_memory();
1708        storage.set_api_key("old-provider", "key123".to_string());
1709        storage
1710            .migrate_provider("old-provider", "new-provider")
1711            .unwrap();
1712
1713        assert!(!storage.has("old-provider"));
1714        assert!(storage.has("new-provider"));
1715        assert_eq!(
1716            storage.get_api_key("new-provider"),
1717            Some("key123".to_string())
1718        );
1719    }
1720
1721    #[test]
1722    fn test_migrate_provider_not_found() {
1723        let storage = AuthStorage::in_memory();
1724        let result = storage.migrate_provider("nonexistent", "target");
1725        assert!(result.is_err());
1726    }
1727
1728    #[test]
1729    fn test_error_draining() {
1730        let storage = AuthStorage::in_memory();
1731        let errors = storage.drain_errors();
1732        assert!(errors.is_empty());
1733    }
1734
1735    #[test]
1736    fn test_fallback_resolver() {
1737        let storage = AuthStorage::in_memory();
1738        storage.set_fallback_resolver(Arc::new(FnFallbackResolver::new(Box::new(|provider| {
1739            if provider == "custom" {
1740                Some("custom-key-from-config".to_string())
1741            } else {
1742                None
1743            }
1744        }))));
1745
1746        assert_eq!(
1747            storage.get_api_key("custom"),
1748            Some("custom-key-from-config".to_string())
1749        );
1750        assert!(storage.get_api_key("unknown").is_none());
1751
1752        // Without fallback
1753        storage.clear_fallback_resolver();
1754        assert!(storage.get_api_key("custom").is_none());
1755    }
1756
1757    #[test]
1758    fn test_get_api_key_with_options() {
1759        let storage = AuthStorage::in_memory();
1760        storage.set_fallback_resolver(Arc::new(FnFallbackResolver::new(Box::new(|_| {
1761            Some("fallback-key".to_string())
1762        }))));
1763
1764        // With fallback
1765        assert_eq!(
1766            storage.get_api_key_with_options("test", true),
1767            Some("fallback-key".to_string())
1768        );
1769
1770        // Without fallback
1771        assert!(storage.get_api_key_with_options("test", false).is_none());
1772    }
1773
1774    #[test]
1775    fn test_configured_providers() {
1776        let storage = AuthStorage::in_memory();
1777        storage.set_api_key("openai", "key".to_string());
1778        storage.set_api_key("anthropic", "key".to_string());
1779
1780        let providers = storage.configured_providers();
1781        assert!(providers.len() >= 2);
1782        // Should be sorted
1783        let mut sorted = providers.clone();
1784        sorted.sort();
1785        assert_eq!(providers, sorted);
1786    }
1787
1788    #[test]
1789    fn test_has_multiple_providers() {
1790        let storage = AuthStorage::in_memory();
1791        assert!(!storage.has_multiple_providers());
1792
1793        storage.set_api_key("openai", "key1".to_string());
1794        assert!(!storage.has_multiple_providers());
1795
1796        storage.set_api_key("anthropic", "key2".to_string());
1797        assert!(storage.has_multiple_providers());
1798    }
1799
1800    #[test]
1801    fn test_set_and_get_credential() {
1802        let storage = AuthStorage::in_memory();
1803        let cred = AuthCredential::Session {
1804            token: "abc".to_string(),
1805            expires_at: 0,
1806            metadata: None,
1807        };
1808        storage.set("custom", cred);
1809        let retrieved = storage.get("custom");
1810        assert!(retrieved.is_some());
1811        assert!(matches!(retrieved.unwrap(), AuthCredential::Session { .. }));
1812    }
1813
1814    #[test]
1815    fn test_credential_type_name() {
1816        assert_eq!(
1817            AuthCredential::ApiKey {
1818                key: "k".to_string()
1819            }
1820            .type_name(),
1821            "api_key"
1822        );
1823        assert_eq!(
1824            AuthCredential::OAuth {
1825                access_token: "t".to_string(),
1826                refresh_token: None,
1827                expires_at: 0,
1828                scopes: None,
1829                provider_data: None,
1830            }
1831            .type_name(),
1832            "oauth"
1833        );
1834        assert_eq!(
1835            AuthCredential::Session {
1836                token: "t".to_string(),
1837                expires_at: 0,
1838                metadata: None,
1839            }
1840            .type_name(),
1841            "session"
1842        );
1843    }
1844}