Skip to main content

oxi/store/
auth_storage.rs

1//! Authentication storage for API keys, OAuth tokens, and session tokens.
2//!
3//! # Storage model (F-1, code audit 2026-07-25)
4//!
5//! Credentials are stored as a plaintext JSON file at `~/.oxi/auth.json`
6//! with Unix `0600` permissions — **not encrypted at rest**. This matches
7//! the convention used by `aws-cli`, `gh`, `kubectl`, and `npm`, all of
8//! which store secrets as mode-`0600` plaintext on the local disk.
9//! Secret fields are masked in [`std::fmt::Debug`] output (see the manual
10//! `Debug` impl on [`AuthCredential`]) so `tracing::debug!`/`dbg!`/panic
11//! backtraces never echo raw keys. An OS-keyring backend is the planned
12//! hardening upgrade; the `#[cfg(feature = "keyring")]` helpers below are
13//! its scaffolding.
14
15use parking_lot::RwLock;
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use std::path::PathBuf;
19use std::sync::{Arc, OnceLock};
20
21// ============================================================================
22// Credential Types
23// ============================================================================
24
25/// Authentication credential
26#[derive(Clone, Serialize, Deserialize)]
27#[serde(tag = "type", rename_all = "snake_case")]
28pub enum AuthCredential {
29    /// API key credential
30    ApiKey {
31        /// The API key string.
32        key: String,
33    },
34    /// OAuth credential with token management
35    OAuth {
36        /// access_token.
37        access_token: String,
38        /// refresh_token.
39        refresh_token: Option<String>,
40        /// expires_at.
41        expires_at: u64,
42        /// Scopes granted (space-separated)
43        #[serde(default)]
44        scopes: Option<String>,
45        /// Provider-specific data (JSON for extensibility)
46        #[serde(default)]
47        provider_data: Option<serde_json::Value>,
48    },
49    /// Session token credential (e.g. from browser-based login)
50    Session {
51        /// token.
52        token: String,
53        /// When the session expires (unix timestamp, 0 = never)
54        #[serde(default)]
55        expires_at: u64,
56        /// Session metadata (user info, etc.)
57        #[serde(default)]
58        metadata: Option<serde_json::Value>,
59    },
60}
61
62// Manual `Debug` — masks every secret field so `dbg!`/`tracing::debug!`/
63// panic backtraces never surface raw API keys, OAuth tokens, or session
64// tokens. Non-secret fields (`expires_at`, `scopes`) are preserved so the
65// output stays useful for diagnosing expiry/lifetime bugs. F-1, code audit
66// 2026-07-25: the prior `#[derive(Debug)]` echoed `key`/`access_token`/
67// `token` verbatim.
68impl 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    /// Check if the credential is expired
103    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; // never expires
112                }
113                *expires_at <= now_secs()
114            }
115            AuthCredential::ApiKey { .. } => false,
116        }
117    }
118
119    /// Check if the token needs refresh (within 60 seconds of expiration)
120    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    /// Get the access token if valid (not expired)
136    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    /// Get the credential type name
145    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    /// Validate the credential structure
154    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                // Check for common placeholder values
161                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/// Credential validation error
192#[derive(Debug, Clone, thiserror::Error)]
193pub enum CredentialValidationError {
194    #[error("Field '{0}' must not be empty")]
195    /// empty field variant.
196    EmptyField(String),
197    #[error("Placeholder value detected: '{0}'")]
198    /// placeholder value variant.
199    PlaceholderValue(String),
200    #[error("Invalid expiry timestamp")]
201    /// invalid expiry variant.
202    InvalidExpiry,
203}
204
205// ============================================================================
206// Auth Status
207// ============================================================================
208
209/// Authentication status
210#[derive(Debug, Clone)]
211pub struct AuthStatus {
212    /// Whether auth is configured
213    pub configured: bool,
214    /// Source of the auth (stored, runtime, environment, fallback)
215    pub source: Option<String>,
216    /// Label for display
217    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
231// ============================================================================
232// Auth Errors
233// ============================================================================
234
235/// Result of an auth operation
236pub type AuthResult<T> = Result<T, AuthError>;
237
238/// Authentication errors
239#[derive(Debug, Clone, thiserror::Error)]
240pub enum AuthError {
241    #[error("Failed to read auth storage: {0}")]
242    /// read error variant.
243    ReadError(String),
244    #[error("Failed to write auth storage: {0}")]
245    /// write error variant.
246    WriteError(String),
247    #[error("Credential not found: {0}")]
248    /// not found variant.
249    NotFound(String),
250    #[error("Invalid credential format: {0}")]
251    /// invalid format variant.
252    InvalidFormat(String),
253    #[error("Keyring error: {0}")]
254    /// keyring error variant.
255    KeyringError(String),
256    #[error("Credential validation failed: {0}")]
257    /// validation failed variant.
258    ValidationFailed(String),
259}
260
261// ============================================================================
262// Storage Backend Trait
263// ============================================================================
264
265/// Storage backend trait
266pub trait AuthStorageBackend: Send + Sync {
267    /// Read stored data
268    fn read(&self) -> AuthResult<Option<String>>;
269    /// Write data
270    fn write(&self, data: &str) -> AuthResult<()>;
271    /// Delete stored data
272    fn delete(&self) -> AuthResult<()>;
273}
274
275// ============================================================================
276// File Backend
277// ============================================================================
278
279/// File-based auth storage backend
280pub struct FileAuthStorage {
281    path: PathBuf,
282    cache: RwLock<Option<String>>,
283}
284
285impl FileAuthStorage {
286    /// Create a new file-based auth storage
287    pub fn new(path: PathBuf) -> Self {
288        Self {
289            path,
290            cache: RwLock::new(None),
291        }
292    }
293
294    /// Get the default auth file path (uses ~/.oxi/auth.json for consistency with settings)
295    pub fn default_path() -> Option<PathBuf> {
296        dirs::home_dir().map(|p| p.join(".oxi").join("auth.json"))
297    }
298
299    /// Get the storage path
300    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        // Ensure parent directory exists with restricted permissions
322        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        // Write the file
334        std::fs::write(&self.path, data).map_err(|e| AuthError::WriteError(e.to_string()))?;
335
336        // Set file permissions to owner-only on Unix
337        #[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
358// ============================================================================
359// Memory Backend
360// ============================================================================
361
362/// Memory-based auth storage (for testing)
363pub struct MemoryAuthStorage {
364    data: RwLock<HashMap<String, AuthCredential>>,
365}
366
367impl MemoryAuthStorage {
368    /// Create a new memory auth storage
369    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        // Memory backend doesn't use JSON serialization
385        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
398// ============================================================================
399// Fallback Resolver
400// ============================================================================
401
402/// Trait for fallback API key resolution (e.g., from models.json config)
403pub trait FallbackResolver: Send + Sync {
404    /// Try to resolve an API key for the given provider
405    fn resolve(&self, provider: &str) -> Option<String>;
406}
407
408/// A simple closure-based fallback resolver
409pub struct FnFallbackResolver {
410    #[allow(clippy::type_complexity)]
411    f: Box<dyn Fn(&str) -> Option<String> + Send + Sync>,
412}
413
414impl FnFallbackResolver {
415    /// Create from a closure
416    #[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
428/// Environment variable fallback resolver.
429///
430/// Uses oxi-ai's `BuiltinProvider` registry to look up the primary and
431/// extra env var names for each provider, then checks `std::env`.
432pub struct EnvVarFallbackResolver;
433
434impl FallbackResolver for EnvVarFallbackResolver {
435    fn resolve(&self, provider: &str) -> Option<String> {
436        // Look up the provider's env key from the builtin registry
437        let builtin = oxi_ai::get_builtin_provider(provider)?;
438        let key = builtin.env_key;
439
440        // Try primary key
441        if let Ok(val) = std::env::var(key)
442            && !val.is_empty()
443        {
444            return Some(val);
445        }
446
447        // Try extra keys
448        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
460// ============================================================================
461// Auth Storage (Main)
462// ============================================================================
463
464/// Main auth storage struct.
465///
466/// Provides multi-layered credential lookup with the following priority:
467/// 1. Runtime override (CLI --api-key)
468/// 2. Stored API key from auth.json
469/// 3. OAuth token from auth.json (with auto-refresh awareness)
470/// 4. Session token from auth.json
471/// 5. Environment variable
472/// 6. Fallback resolver (e.g., custom provider config from models.json)
473pub struct AuthStorage {
474    /// File-based storage backend
475    file_storage: Option<Arc<dyn AuthStorageBackend>>,
476    /// In-memory credential cache
477    credentials: RwLock<HashMap<String, AuthCredential>>,
478    /// Runtime overrides (CLI --api-key)
479    runtime_overrides: RwLock<HashMap<String, String>>,
480    /// Fallback resolver for custom providers
481    fallback_resolver: RwLock<Option<Arc<dyn FallbackResolver>>>,
482    /// Collected errors
483    errors: RwLock<Vec<AuthError>>,
484    /// Whether initial load had an error
485    load_error: RwLock<Option<AuthError>>,
486    /// OnceLock to warn about plaintext storage only once
487    plaintext_warned: OnceLock<()>,
488}
489
490impl AuthStorage {
491    /// Create a new auth storage with default file backend
492    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    /// Create with explicit storage backend
517    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    /// Create a memory-only storage (for testing)
535    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    /// Get the default auth file path
548    pub fn default_path() -> Option<PathBuf> {
549        FileAuthStorage::default_path()
550    }
551
552    // -----------------------------------------------------------------------
553    // Runtime overrides
554    // -----------------------------------------------------------------------
555
556    /// Set a runtime API key override (from CLI --api-key)
557    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    /// Remove a runtime override
564    pub fn remove_runtime_key(&self, provider: &str) {
565        self.runtime_overrides.write().remove(provider);
566    }
567
568    // -----------------------------------------------------------------------
569    // Fallback resolver
570    // -----------------------------------------------------------------------
571
572    /// Set a fallback resolver for API keys not found in auth.json or env vars.
573    /// Used for custom provider keys from models.json.
574    pub fn set_fallback_resolver(&self, resolver: Arc<dyn FallbackResolver>) {
575        *self.fallback_resolver.write() = Some(resolver);
576    }
577
578    /// Clear the fallback resolver
579    pub fn clear_fallback_resolver(&self) {
580        *self.fallback_resolver.write() = None;
581    }
582
583    // -----------------------------------------------------------------------
584    // Credential query
585    // -----------------------------------------------------------------------
586
587    /// Check if a provider has any auth configured
588    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    /// Get auth status for a provider (without exposing credentials)
604    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    /// Get API key for a provider.
639    ///
640    /// Priority:
641    /// 1. Runtime override (CLI --api-key)
642    /// 2. Stored API key from auth.json
643    /// 3. OAuth token from auth.json (auto-refreshed)
644    /// 4. Session token from auth.json
645    /// 5. Fallback resolver
646    pub fn get_api_key(&self, provider: &str) -> Option<String> {
647        self.get_api_key_with_options(provider, true)
648    }
649
650    /// Get API key with option to include/exclude fallback resolver
651    pub fn get_api_key_with_options(
652        &self,
653        provider: &str,
654        include_fallback: bool,
655    ) -> Option<String> {
656        // 1. Runtime override
657        if let Some(key) = self.runtime_overrides.read().get(provider) {
658            return Some(key.clone());
659        }
660
661        // 2-4. Stored credential
662        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                        // Token expired - caller should handle refresh
674                        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        // 5. Cross-provider alias lookup:
690        //    If the key is stored under a different provider name that shares
691        //    the same env_key (e.g. key stored as "zai-coding-global" but
692        //    looked up as "zai"), check those providers' stored credentials.
693        if let Some(builtin) = oxi_ai::register_builtins::get_builtin_provider(provider) {
694            let env_key = builtin.env_key;
695            let credentials = self.credentials.read();
696            for other in oxi_ai::register_builtins::get_builtin_providers() {
697                if other.name == provider {
698                    continue; // already checked above
699                }
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        // 6. Fallback resolver
731        if include_fallback && let Some(ref resolver) = *self.fallback_resolver.read() {
732            return resolver.resolve(provider);
733        }
734
735        None
736    }
737
738    // -----------------------------------------------------------------------
739    // Credential mutation
740    // -----------------------------------------------------------------------
741
742    /// Set API key for a provider
743    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    /// Set OAuth credential for a provider
753    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    /// Set OAuth credential with full details
771    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    /// Set session token for a provider
796    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    /// Update an existing OAuth credential (for token refresh)
817    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    // -----------------------------------------------------------------------
860    // Credential retrieval
861    // -----------------------------------------------------------------------
862
863    /// Get credential for a provider
864    pub fn get(&self, provider: &str) -> Option<AuthCredential> {
865        self.credentials.read().get(provider).cloned()
866    }
867
868    /// Get OAuth credential for a provider (for token refresh)
869    pub fn get_oauth_credential(&self, provider: &str) -> Option<AuthCredential> {
870        self.credentials.read().get(provider).cloned()
871    }
872
873    /// Check if a provider has OAuth credentials that can be refreshed
874    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    // -----------------------------------------------------------------------
889    // CRUD operations
890    // -----------------------------------------------------------------------
891
892    /// Set a credential for a provider
893    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    /// Remove credential for a provider
903    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    /// List all providers with credentials
911    pub fn list_providers(&self) -> Vec<String> {
912        self.credentials.read().keys().cloned().collect()
913    }
914
915    /// Check if credential exists for provider in storage
916    pub fn has(&self, provider: &str) -> bool {
917        self.credentials.read().contains_key(provider)
918    }
919
920    /// Get all credentials
921    pub fn get_all(&self) -> HashMap<String, AuthCredential> {
922        self.credentials.read().clone()
923    }
924
925    /// Clear all stored credentials
926    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    // -----------------------------------------------------------------------
934    // Persistence
935    // -----------------------------------------------------------------------
936
937    /// Reload from disk
938    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    /// Persist to disk
962    #[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                // F-4 (audit 2026-06-21): the previous warning suggested
968                // enabling a `keyring` feature that is never declared in
969                // `Cargo.toml` (the `keyring_support` module at line 1047
970                // is dead code: `#[cfg(feature = "keyring")]` is never
971                // selected, so `cargo` always builds the `not(feature)`
972                // branch). Replace with an accurate one-shot warning that
973                // names the actual on-disk path and points at the docs.
974                self.plaintext_warned.get_or_init(|| {
975                    tracing::warn!(
976                        "Auth credentials are stored in plaintext at \
977                         ~/.oxi/auth.json (mode 0600). For OS-keyring \
978                         support, see the `oxi-auth-keyring` crate or \
979                         the OXI_KEYRING=1 docs at docs/PORT_GUIDE.md."
980                    );
981                });
982
983                if let Err(e) = storage.write(&json) {
984                    tracing::error!("Failed to persist auth storage: {}", e);
985                    self.record_error(e);
986                    return Err("persist failed".to_string());
987                }
988            }
989        }
990        Ok(())
991    }
992
993    // -----------------------------------------------------------------------
994    // Error tracking
995    // -----------------------------------------------------------------------
996
997    /// Record an error
998    fn record_error(&self, error: AuthError) {
999        self.errors.write().push(error);
1000    }
1001
1002    /// Drain collected errors
1003    pub fn drain_errors(&self) -> Vec<AuthError> {
1004        let mut errors = self.errors.write();
1005        std::mem::take(&mut *errors)
1006    }
1007
1008    /// Get the last load error
1009    pub fn load_error(&self) -> Option<AuthError> {
1010        self.load_error.read().clone()
1011    }
1012
1013    // -----------------------------------------------------------------------
1014    // Validation
1015    // -----------------------------------------------------------------------
1016
1017    /// Validate all stored credentials
1018    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    /// Validate credential for a specific provider
1030    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    // -----------------------------------------------------------------------
1042    // Multi-provider support
1043    // -----------------------------------------------------------------------
1044
1045    /// Get all configured provider IDs (sorted)
1046    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    /// Check if multiple providers are configured
1053    pub fn has_multiple_providers(&self) -> bool {
1054        self.credentials.read().len() > 1
1055    }
1056
1057    /// Get the primary provider (first configured, preferring stored over env)
1058    pub fn primary_provider(&self) -> Option<String> {
1059        let creds = self.credentials.read();
1060        creds.keys().next().cloned()
1061    }
1062
1063    /// Migrate credentials from one provider to another
1064    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
1082// ============================================================================
1083// SDK AuthProvider port adapter
1084// ============================================================================
1085//
1086// AuthStorage is the CLI's actual credential store: every TUI overlay
1087// (provider_select.rs, slash/builtin/provider.rs, …) writes here via
1088// `shared_auth_storage()`. Registering it DIRECTLY as the SDK `AuthProvider`
1089// port (replacing FileAuthProvider in `build_oxi`) eliminates both the
1090// dual-cache problem (the port IS what overlays write to) and the schema
1091// mismatch (FileAuthProvider's `{version,providers:{name:{api_key}}}`
1092// format can't deserialize AuthStorage's `#[serde(tag="type")]` enum
1093// output — see issue #40 advisor follow-up).
1094//
1095// The sync fast-path is the load-bearing piece: `Oxi::create_provider`
1096// consults it at build / switch_model / refresh_credentials time.
1097
1098impl oxi_sdk::ports::AuthProvider for AuthStorage {
1099    fn get_api_key(
1100        &self,
1101        provider: &str,
1102    ) -> std::pin::Pin<
1103        Box<
1104            dyn std::future::Future<Output = Result<Option<String>, oxi_sdk::SdkError>> + Send + '_,
1105        >,
1106    > {
1107        let result = self.get_api_key(provider);
1108        Box::pin(async move { Ok(result) })
1109    }
1110
1111    /// Sync fast-path — delegates directly to AuthStorage's existing sync
1112    /// `get_api_key`, which already implements the full lookup chain
1113    /// (runtime override → file → OAuth → session → env → fallback).
1114    /// This is the credential source `Oxi::create_provider` consults.
1115    fn get_api_key_sync(&self, provider: &str) -> Result<Option<String>, oxi_sdk::SdkError> {
1116        Ok(self.get_api_key(provider))
1117    }
1118
1119    fn set_api_key(
1120        &self,
1121        provider: &str,
1122        key: &str,
1123    ) -> std::pin::Pin<
1124        Box<dyn std::future::Future<Output = Result<(), oxi_sdk::SdkError>> + Send + '_>,
1125    > {
1126        AuthStorage::set_api_key(self, provider, key.to_string());
1127        Box::pin(async { Ok(()) })
1128    }
1129
1130    fn delete_api_key(
1131        &self,
1132        provider: &str,
1133    ) -> std::pin::Pin<
1134        Box<dyn std::future::Future<Output = Result<(), oxi_sdk::SdkError>> + Send + '_>,
1135    > {
1136        self.credentials.write().remove(provider);
1137        let _ = self.persist();
1138        Box::pin(async { Ok(()) })
1139    }
1140
1141    fn get_oauth(
1142        &self,
1143        provider: &str,
1144    ) -> std::pin::Pin<
1145        Box<
1146            dyn std::future::Future<
1147                    Output = Result<Option<oxi_sdk::ports::OAuthToken>, oxi_sdk::SdkError>,
1148                > + Send
1149                + '_,
1150        >,
1151    > {
1152        let result = self
1153            .get_oauth_credential(provider)
1154            .and_then(|cred| match cred {
1155                AuthCredential::OAuth {
1156                    access_token,
1157                    refresh_token,
1158                    expires_at,
1159                    ..
1160                } => Some(oxi_sdk::ports::OAuthToken {
1161                    access_token,
1162                    refresh_token,
1163                    expires_at: chrono::DateTime::from_timestamp(expires_at as i64, 0),
1164                    token_type: Some("Bearer".to_string()),
1165                    scope: None,
1166                }),
1167                _ => None,
1168            });
1169        Box::pin(async move { Ok(result) })
1170    }
1171
1172    fn set_oauth(
1173        &self,
1174        provider: &str,
1175        token: oxi_sdk::ports::OAuthToken,
1176    ) -> std::pin::Pin<
1177        Box<dyn std::future::Future<Output = Result<(), oxi_sdk::SdkError>> + Send + '_>,
1178    > {
1179        let expires_at = token
1180            .expires_at
1181            .and_then(|dt| dt.timestamp().max(0).try_into().ok())
1182            .unwrap_or(0);
1183        AuthStorage::set_oauth(
1184            self,
1185            provider,
1186            token.access_token,
1187            token.refresh_token,
1188            expires_at,
1189        );
1190        Box::pin(async { Ok(()) })
1191    }
1192
1193    fn list_providers(
1194        &self,
1195    ) -> std::pin::Pin<
1196        Box<dyn std::future::Future<Output = Result<Vec<String>, oxi_sdk::SdkError>> + Send + '_>,
1197    > {
1198        let result = AuthStorage::list_providers(self);
1199        Box::pin(async move { Ok(result) })
1200    }
1201}
1202
1203// ============================================================================
1204// Helper: current unix timestamp
1205// ============================================================================
1206
1207fn now_secs() -> u64 {
1208    std::time::SystemTime::now()
1209        .duration_since(std::time::UNIX_EPOCH)
1210        .map(|d| d.as_secs())
1211        .unwrap_or(0)
1212}
1213
1214// ============================================================================
1215// Keyring Support
1216// ============================================================================
1217//
1218// F-4 (audit 2026-06-21): this module is currently unreachable from the
1219// workspace because the `keyring` feature is not declared in `Cargo.toml`.
1220// It is preserved so a follow-up PR can wire it back in via
1221// `keyring = { version = "2", optional = true }` + `keyring = ["dep:keyring"]`
1222// and add an opt-in env-gated call site in `FileAuthStorage::persist`.
1223// Until that PR lands, the `not(feature = "keyring")` arm always compiles
1224// and the `cfg(feature = "keyring")` arm is dead.
1225
1226/// OS-keyring credential helpers. Currently stubbed because the
1227/// `keyring` cargo feature is not enabled (see F-4 audit note above).
1228#[allow(unexpected_cfgs)]
1229#[deprecated(note = "keyring cargo feature is not wired in Cargo.toml; \
1230    this module is currently a no-op fallback. See docs/PORT_GUIDE.md \
1231    and the F-4 audit note in oxi-cli/src/store/auth_storage.rs.")]
1232pub mod keyring_support {
1233    use super::*;
1234
1235    /// Try to get a secret from the OS keyring
1236    #[cfg(feature = "keyring")]
1237    pub fn get_keyring_secret(service: &str, account: &str) -> Option<String> {
1238        use keyring::Entry;
1239        Entry::new(service, account)
1240            .ok()
1241            .and_then(|entry| entry.get_password().ok())
1242    }
1243
1244    /// Try to set a secret in the OS keyring
1245    #[cfg(feature = "keyring")]
1246    pub fn set_keyring_secret(service: &str, account: &str, secret: &str) -> AuthResult<()> {
1247        use keyring::Entry;
1248        Entry::new(service, account)
1249            .map_err(|e| AuthError::KeyringError(e.to_string()))?
1250            .set_password(secret)
1251            .map_err(|e| AuthError::KeyringError(e.to_string()))
1252    }
1253
1254    /// Try to delete a secret from the OS keyring
1255    #[cfg(feature = "keyring")]
1256    pub fn delete_keyring_secret(service: &str, account: &str) -> AuthResult<()> {
1257        use keyring::Entry;
1258        Entry::new(service, account)
1259            .map_err(|e| AuthError::KeyringError(e.to_string()))?
1260            .delete_credential()
1261            .map_err(|e| AuthError::KeyringError(e.to_string()))
1262    }
1263
1264    // Non-keyring fallbacks
1265    #[cfg(not(feature = "keyring"))]
1266    /// Retrieve a secret from the OS keyring.
1267    ///
1268    /// Returns `None` when the keyring feature is not compiled in.
1269    pub fn get_keyring_secret(_service: &str, _account: &str) -> Option<String> {
1270        None
1271    }
1272
1273    #[cfg(not(feature = "keyring"))]
1274    /// Store a secret in the OS keyring.
1275    ///
1276    /// Returns an error when the keyring feature is not compiled in.
1277    pub fn set_keyring_secret(_service: &str, _account: &str, _secret: &str) -> AuthResult<()> {
1278        Err(AuthError::KeyringError(
1279            "Keyring support not compiled".to_string(),
1280        ))
1281    }
1282
1283    #[cfg(not(feature = "keyring"))]
1284    /// Delete a secret from the OS keyring.
1285    ///
1286    /// Returns an error when the keyring feature is not compiled in.
1287    pub fn delete_keyring_secret(_service: &str, _account: &str) -> AuthResult<()> {
1288        Err(AuthError::KeyringError(
1289            "Keyring support not compiled".to_string(),
1290        ))
1291    }
1292}
1293
1294// ============================================================================
1295// Singleton
1296// ============================================================================
1297
1298/// Get a shared singleton `Arc<AuthStorage>` instance.
1299///
1300/// Avoids creating multiple `AuthStorage::new()` instances that each
1301/// independently read and cache `auth.json`. All callers share the same
1302/// in-memory state through the `Arc`.
1303pub fn shared_auth_storage() -> Arc<AuthStorage> {
1304    static STORAGE: OnceLock<Arc<AuthStorage>> = OnceLock::new();
1305    STORAGE
1306        .get_or_init(|| {
1307            let storage = Arc::new(AuthStorage::new());
1308            // Default fallback: resolve API keys from environment variables
1309            // using the BuiltinProvider registry (env_key + extra_env_keys).
1310            storage.set_fallback_resolver(Arc::new(EnvVarFallbackResolver));
1311            storage
1312        })
1313        .clone()
1314}
1315
1316// ============================================================================
1317// Tests
1318// ============================================================================
1319
1320#[cfg(test)]
1321mod tests {
1322    use super::*;
1323
1324    #[test]
1325    fn test_auth_storage_new() {
1326        let storage = AuthStorage::in_memory();
1327        assert!(!storage.has("anthropic"));
1328    }
1329
1330    #[test]
1331    fn test_set_and_get_api_key() {
1332        let storage = AuthStorage::in_memory();
1333        storage.set_api_key("anthropic", "sk-test123".to_string());
1334        assert!(storage.has("anthropic"));
1335        assert_eq!(
1336            storage.get_api_key("anthropic"),
1337            Some("sk-test123".to_string())
1338        );
1339    }
1340
1341    #[test]
1342    fn test_runtime_override() {
1343        let storage = AuthStorage::in_memory();
1344        storage.set_api_key("anthropic", "stored-key".to_string());
1345        storage.set_runtime_key("anthropic", "runtime-key".to_string());
1346
1347        // Runtime key should take priority
1348        assert_eq!(
1349            storage.get_api_key("anthropic"),
1350            Some("runtime-key".to_string())
1351        );
1352    }
1353
1354    #[test]
1355    fn test_remove_credential() {
1356        let storage = AuthStorage::in_memory();
1357        storage.set_api_key("anthropic", "sk-test123".to_string());
1358        assert!(storage.has("anthropic"));
1359
1360        storage.remove("anthropic");
1361        assert!(!storage.has("anthropic"));
1362    }
1363
1364    #[test]
1365    fn test_auth_status() {
1366        let storage = AuthStorage::in_memory();
1367        storage.set_api_key("anthropic", "sk-test123".to_string());
1368
1369        let status = storage.get_status("anthropic");
1370        assert!(status.configured);
1371        assert_eq!(status.source, Some("stored".to_string()));
1372        assert_eq!(status.label, Some("api_key".to_string()));
1373    }
1374
1375    #[test]
1376    fn test_auth_status_display() {
1377        let status = AuthStatus {
1378            configured: true,
1379            source: Some("stored".to_string()),
1380            label: Some("api_key".to_string()),
1381        };
1382        let display = format!("{}", status);
1383        assert_eq!(display, "stored (api_key)");
1384
1385        let no_config = AuthStatus {
1386            configured: false,
1387            source: None,
1388            label: None,
1389        };
1390        assert_eq!(format!("{}", no_config), "not configured");
1391    }
1392
1393    #[test]
1394    fn test_list_providers() {
1395        let storage = AuthStorage::in_memory();
1396        storage.set_api_key("anthropic", "key1".to_string());
1397        storage.set_api_key("openai", "key2".to_string());
1398
1399        let providers = storage.list_providers();
1400        assert!(providers.contains(&"anthropic".to_string()));
1401        assert!(providers.contains(&"openai".to_string()));
1402    }
1403
1404    #[test]
1405    fn test_oauth_credential() {
1406        let storage = AuthStorage::in_memory();
1407        storage.set_oauth(
1408            "provider",
1409            "access123".to_string(),
1410            Some("refresh456".to_string()),
1411            u64::MAX,
1412        );
1413
1414        assert!(storage.has("provider"));
1415        assert_eq!(
1416            storage.get_api_key("provider"),
1417            Some("access123".to_string())
1418        );
1419    }
1420
1421    #[test]
1422    fn test_expired_oauth_token() {
1423        let storage = AuthStorage::in_memory();
1424        // Set token that expired in the past
1425        storage.set_oauth("provider", "access123".to_string(), None, 0);
1426
1427        // Token should be treated as expired
1428        let key = storage.get_api_key("provider");
1429        assert!(key.is_none());
1430    }
1431
1432    #[test]
1433    fn test_get_all_credentials() {
1434        let storage = AuthStorage::in_memory();
1435        storage.set_api_key("anthropic", "key1".to_string());
1436        storage.set_api_key("openai", "key2".to_string());
1437
1438        let all = storage.get_all();
1439        assert_eq!(all.len(), 2);
1440    }
1441
1442    #[test]
1443    fn test_clear() {
1444        let storage = AuthStorage::in_memory();
1445        storage.set_api_key("anthropic", "key".to_string());
1446        assert!(storage.has("anthropic"));
1447
1448        storage.clear();
1449        assert!(!storage.has("anthropic"));
1450    }
1451
1452    #[test]
1453    fn test_remove_runtime_key() {
1454        let storage = AuthStorage::in_memory();
1455        storage.set_api_key("anthropic", "stored".to_string());
1456        storage.set_runtime_key("anthropic", "runtime".to_string());
1457
1458        assert_eq!(
1459            storage.get_api_key("anthropic"),
1460            Some("runtime".to_string())
1461        );
1462
1463        storage.remove_runtime_key("anthropic");
1464        assert_eq!(storage.get_api_key("anthropic"), Some("stored".to_string()));
1465    }
1466
1467    #[test]
1468    fn test_auth_credential_is_expired() {
1469        // API key never expires
1470        let api_key_cred = AuthCredential::ApiKey {
1471            key: "test".to_string(),
1472        };
1473        assert!(!api_key_cred.is_expired());
1474
1475        // OAuth token that expires in the future
1476        let future_time = now_secs() + 3600;
1477        let oauth_cred = AuthCredential::OAuth {
1478            access_token: "token".to_string(),
1479            refresh_token: Some("refresh".to_string()),
1480            expires_at: future_time,
1481            scopes: None,
1482            provider_data: None,
1483        };
1484        assert!(!oauth_cred.is_expired());
1485
1486        // OAuth token that expired in the past
1487        let oauth_cred_expired = AuthCredential::OAuth {
1488            access_token: "token".to_string(),
1489            refresh_token: Some("refresh".to_string()),
1490            expires_at: 0,
1491            scopes: None,
1492            provider_data: None,
1493        };
1494        assert!(oauth_cred_expired.is_expired());
1495    }
1496
1497    #[test]
1498    fn test_auth_credential_needs_refresh() {
1499        let future_time = now_secs() + 120; // 2 minutes from now
1500
1501        // Has refresh token, will expire soon - not yet within 60s
1502        let oauth_cred = AuthCredential::OAuth {
1503            access_token: "token".to_string(),
1504            refresh_token: Some("refresh".to_string()),
1505            expires_at: future_time,
1506            scopes: None,
1507            provider_data: None,
1508        };
1509        assert!(!oauth_cred.needs_refresh());
1510
1511        // Within 60 seconds
1512        let soon = now_secs() + 30;
1513        let oauth_soon = AuthCredential::OAuth {
1514            access_token: "token".to_string(),
1515            refresh_token: Some("refresh".to_string()),
1516            expires_at: soon,
1517            scopes: None,
1518            provider_data: None,
1519        };
1520        assert!(oauth_soon.needs_refresh());
1521
1522        // No refresh token - doesn't need refresh
1523        let no_refresh = AuthCredential::OAuth {
1524            access_token: "token".to_string(),
1525            refresh_token: None,
1526            expires_at: future_time,
1527            scopes: None,
1528            provider_data: None,
1529        };
1530        assert!(!no_refresh.needs_refresh());
1531
1532        // API key never needs refresh
1533        let api_key_cred = AuthCredential::ApiKey {
1534            key: "test".to_string(),
1535        };
1536        assert!(!api_key_cred.needs_refresh());
1537    }
1538
1539    #[test]
1540    fn test_auth_credential_access_token() {
1541        let future_time = now_secs() + 3600;
1542
1543        let oauth_cred = AuthCredential::OAuth {
1544            access_token: "valid_token".to_string(),
1545            refresh_token: Some("refresh".to_string()),
1546            expires_at: future_time,
1547            scopes: None,
1548            provider_data: None,
1549        };
1550        assert_eq!(oauth_cred.access_token(), Some("valid_token"));
1551
1552        // Expired token
1553        let expired_cred = AuthCredential::OAuth {
1554            access_token: "expired_token".to_string(),
1555            refresh_token: Some("refresh".to_string()),
1556            expires_at: 0,
1557            scopes: None,
1558            provider_data: None,
1559        };
1560        assert!(expired_cred.access_token().is_none());
1561
1562        // API key returns None via access_token
1563        let api_key_cred = AuthCredential::ApiKey {
1564            key: "api_key_token".to_string(),
1565        };
1566        assert!(api_key_cred.access_token().is_none());
1567    }
1568
1569    #[test]
1570    fn test_get_oauth_credential() {
1571        let storage = AuthStorage::in_memory();
1572        storage.set_oauth(
1573            "provider",
1574            "access".to_string(),
1575            Some("refresh".to_string()),
1576            u64::MAX,
1577        );
1578
1579        let cred = storage.get_oauth_credential("provider");
1580        assert!(cred.is_some());
1581        assert!(matches!(cred.unwrap(), AuthCredential::OAuth { .. }));
1582    }
1583
1584    #[test]
1585    fn test_has_oauth_with_refresh() {
1586        let storage = AuthStorage::in_memory();
1587
1588        // With refresh token
1589        storage.set_oauth(
1590            "with_refresh",
1591            "access".to_string(),
1592            Some("refresh".to_string()),
1593            u64::MAX,
1594        );
1595        assert!(storage.has_oauth_with_refresh("with_refresh"));
1596
1597        // Without refresh token
1598        storage.set_oauth("without_refresh", "access".to_string(), None, u64::MAX);
1599        assert!(!storage.has_oauth_with_refresh("without_refresh"));
1600
1601        // API key provider
1602        storage.set_api_key("apikey_provider", "key".to_string());
1603        assert!(!storage.has_oauth_with_refresh("apikey_provider"));
1604    }
1605
1606    #[test]
1607    fn test_set_oauth_full() {
1608        let storage = AuthStorage::in_memory();
1609        storage.set_oauth_full(
1610            "provider",
1611            "access_token".to_string(),
1612            Some("refresh_token".to_string()),
1613            3600,
1614            Some("read write".to_string()),
1615            Some(serde_json::json!({"extra": "data"})),
1616        );
1617
1618        let cred = storage.get_oauth_credential("provider");
1619        assert!(cred.is_some());
1620        if let AuthCredential::OAuth {
1621            scopes,
1622            provider_data,
1623            ..
1624        } = cred.unwrap()
1625        {
1626            assert_eq!(scopes, Some("read write".to_string()));
1627            assert!(provider_data.is_some());
1628        } else {
1629            panic!("Expected OAuth credential");
1630        }
1631    }
1632
1633    #[test]
1634    fn test_session_token() {
1635        let storage = AuthStorage::in_memory();
1636        storage.set_session(
1637            "browser",
1638            "session-token-123".to_string(),
1639            0, // never expires
1640            Some(serde_json::json!({"user": "test"})),
1641        );
1642
1643        assert!(storage.has("browser"));
1644        assert_eq!(
1645            storage.get_api_key("browser"),
1646            Some("session-token-123".to_string())
1647        );
1648
1649        let cred = storage.get("browser").unwrap();
1650        assert!(matches!(cred, AuthCredential::Session { .. }));
1651        assert!(cred.access_token().is_some());
1652    }
1653
1654    #[test]
1655    fn test_session_token_expired() {
1656        let storage = AuthStorage::in_memory();
1657        storage.set_session("browser", "session-token".to_string(), 1, None);
1658
1659        // Token expired (timestamp 1 is in the past)
1660        assert!(storage.get_api_key("browser").is_none());
1661    }
1662
1663    #[test]
1664    fn test_credential_validation() {
1665        // Valid API key
1666        let valid = AuthCredential::ApiKey {
1667            key: "sk-valid".to_string(),
1668        };
1669        assert!(valid.validate().is_ok());
1670
1671        // Empty API key
1672        let empty = AuthCredential::ApiKey {
1673            key: "".to_string(),
1674        };
1675        assert!(empty.validate().is_err());
1676
1677        // Placeholder
1678        let placeholder = AuthCredential::ApiKey {
1679            key: "your-api-key-here".to_string(),
1680        };
1681        assert!(placeholder.validate().is_err());
1682
1683        // Valid OAuth
1684        let valid_oauth = AuthCredential::OAuth {
1685            access_token: "token".to_string(),
1686            refresh_token: None,
1687            expires_at: now_secs() + 3600,
1688            scopes: None,
1689            provider_data: None,
1690        };
1691        assert!(valid_oauth.validate().is_ok());
1692
1693        // Invalid OAuth (empty token)
1694        let invalid_oauth = AuthCredential::OAuth {
1695            access_token: "".to_string(),
1696            refresh_token: None,
1697            expires_at: 1000,
1698            scopes: None,
1699            provider_data: None,
1700        };
1701        assert!(invalid_oauth.validate().is_err());
1702    }
1703
1704    #[test]
1705    fn test_validate_all() {
1706        let storage = AuthStorage::in_memory();
1707        storage.set_api_key("valid", "sk-good".to_string());
1708        storage.set_api_key("empty", "".to_string());
1709
1710        let errors = storage.validate_all();
1711        assert_eq!(errors.len(), 1);
1712        assert_eq!(errors[0].0, "empty");
1713    }
1714
1715    #[test]
1716    fn test_update_oauth_tokens() {
1717        let storage = AuthStorage::in_memory();
1718        storage.set_oauth(
1719            "provider",
1720            "old-access".to_string(),
1721            Some("old-refresh".to_string()),
1722            now_secs() + 3600,
1723        );
1724
1725        storage
1726            .update_oauth_tokens(
1727                "provider",
1728                "new-access".to_string(),
1729                Some("new-refresh".to_string()),
1730                now_secs() + 7200,
1731            )
1732            .unwrap();
1733
1734        let key = storage.get_api_key("provider");
1735        assert_eq!(key, Some("new-access".to_string()));
1736    }
1737
1738    #[test]
1739    fn test_update_oauth_tokens_wrong_type() {
1740        let storage = AuthStorage::in_memory();
1741        storage.set_api_key("provider", "key".to_string());
1742
1743        let result = storage.update_oauth_tokens(
1744            "provider",
1745            "new-access".to_string(),
1746            None,
1747            now_secs() + 3600,
1748        );
1749        assert!(result.is_err());
1750    }
1751
1752    #[test]
1753    fn test_migrate_provider() {
1754        let storage = AuthStorage::in_memory();
1755        storage.set_api_key("old-provider", "key123".to_string());
1756        storage
1757            .migrate_provider("old-provider", "new-provider")
1758            .unwrap();
1759
1760        assert!(!storage.has("old-provider"));
1761        assert!(storage.has("new-provider"));
1762        assert_eq!(
1763            storage.get_api_key("new-provider"),
1764            Some("key123".to_string())
1765        );
1766    }
1767
1768    #[test]
1769    fn test_migrate_provider_not_found() {
1770        let storage = AuthStorage::in_memory();
1771        let result = storage.migrate_provider("nonexistent", "target");
1772        assert!(result.is_err());
1773    }
1774
1775    #[test]
1776    fn test_error_draining() {
1777        let storage = AuthStorage::in_memory();
1778        let errors = storage.drain_errors();
1779        assert!(errors.is_empty());
1780    }
1781
1782    #[test]
1783    fn test_fallback_resolver() {
1784        let storage = AuthStorage::in_memory();
1785        storage.set_fallback_resolver(Arc::new(FnFallbackResolver::new(Box::new(|provider| {
1786            if provider == "custom" {
1787                Some("custom-key-from-config".to_string())
1788            } else {
1789                None
1790            }
1791        }))));
1792
1793        assert_eq!(
1794            storage.get_api_key("custom"),
1795            Some("custom-key-from-config".to_string())
1796        );
1797        assert!(storage.get_api_key("unknown").is_none());
1798
1799        // Without fallback
1800        storage.clear_fallback_resolver();
1801        assert!(storage.get_api_key("custom").is_none());
1802    }
1803
1804    #[test]
1805    fn test_get_api_key_with_options() {
1806        let storage = AuthStorage::in_memory();
1807        storage.set_fallback_resolver(Arc::new(FnFallbackResolver::new(Box::new(|_| {
1808            Some("fallback-key".to_string())
1809        }))));
1810
1811        // With fallback
1812        assert_eq!(
1813            storage.get_api_key_with_options("test", true),
1814            Some("fallback-key".to_string())
1815        );
1816
1817        // Without fallback
1818        assert!(storage.get_api_key_with_options("test", false).is_none());
1819    }
1820
1821    #[test]
1822    fn test_configured_providers() {
1823        let storage = AuthStorage::in_memory();
1824        storage.set_api_key("openai", "key".to_string());
1825        storage.set_api_key("anthropic", "key".to_string());
1826
1827        let providers = storage.configured_providers();
1828        assert!(providers.len() >= 2);
1829        // Should be sorted
1830        let mut sorted = providers.clone();
1831        sorted.sort();
1832        assert_eq!(providers, sorted);
1833    }
1834
1835    #[test]
1836    fn test_has_multiple_providers() {
1837        let storage = AuthStorage::in_memory();
1838        assert!(!storage.has_multiple_providers());
1839
1840        storage.set_api_key("openai", "key1".to_string());
1841        assert!(!storage.has_multiple_providers());
1842
1843        storage.set_api_key("anthropic", "key2".to_string());
1844        assert!(storage.has_multiple_providers());
1845    }
1846
1847    #[test]
1848    fn test_set_and_get_credential() {
1849        let storage = AuthStorage::in_memory();
1850        let cred = AuthCredential::Session {
1851            token: "abc".to_string(),
1852            expires_at: 0,
1853            metadata: None,
1854        };
1855        storage.set("custom", cred);
1856        let retrieved = storage.get("custom");
1857        assert!(retrieved.is_some());
1858        assert!(matches!(retrieved.unwrap(), AuthCredential::Session { .. }));
1859    }
1860
1861    #[test]
1862    fn test_credential_type_name() {
1863        assert_eq!(
1864            AuthCredential::ApiKey {
1865                key: "k".to_string()
1866            }
1867            .type_name(),
1868            "api_key"
1869        );
1870        assert_eq!(
1871            AuthCredential::OAuth {
1872                access_token: "t".to_string(),
1873                refresh_token: None,
1874                expires_at: 0,
1875                scopes: None,
1876                provider_data: None,
1877            }
1878            .type_name(),
1879            "oauth"
1880        );
1881        assert_eq!(
1882            AuthCredential::Session {
1883                token: "t".to_string(),
1884                expires_at: 0,
1885                metadata: None,
1886            }
1887            .type_name(),
1888            "session"
1889        );
1890    }
1891
1892    // ── F-1 secret masking (code audit 2026-07-25) ──────────────────────
1893
1894    #[test]
1895    fn debug_impl_masks_api_key() {
1896        let cred = AuthCredential::ApiKey {
1897            key: "sk-super-secret-12345".to_string(),
1898        };
1899        let dbg = format!("{cred:?}");
1900        assert!(
1901            !dbg.contains("sk-super-secret-12345"),
1902            "Debug must not leak the API key, got: {dbg}"
1903        );
1904        assert!(
1905            dbg.contains("<redacted>"),
1906            "Debug must show <redacted> placeholder, got: {dbg}"
1907        );
1908    }
1909
1910    #[test]
1911    fn debug_impl_masks_oauth_tokens() {
1912        let cred = AuthCredential::OAuth {
1913            access_token: "ya29.access-token-value".to_string(),
1914            refresh_token: Some("ya29.refresh-token-value".to_string()),
1915            expires_at: 9999999999,
1916            scopes: None,
1917            provider_data: None,
1918        };
1919        let dbg = format!("{cred:?}");
1920        assert!(
1921            !dbg.contains("access-token-value"),
1922            "Debug must not leak the access token, got: {dbg}"
1923        );
1924        assert!(
1925            !dbg.contains("refresh-token-value"),
1926            "Debug must not leak the refresh token, got: {dbg}"
1927        );
1928        // Non-secret fields stay visible for debugging expiry issues.
1929        assert!(
1930            dbg.contains("9999999999"),
1931            "Debug must still show expires_at for diagnostics, got: {dbg}"
1932        );
1933    }
1934
1935    #[test]
1936    fn debug_impl_masks_session_token() {
1937        let cred = AuthCredential::Session {
1938            token: "sess-abc-def-ghi".to_string(),
1939            expires_at: 0,
1940            metadata: None,
1941        };
1942        let dbg = format!("{cred:?}");
1943        assert!(
1944            !dbg.contains("sess-abc-def-ghi"),
1945            "Debug must not leak the session token, got: {dbg}"
1946        );
1947        assert!(
1948            dbg.contains("<redacted>"),
1949            "Debug must show <redacted> placeholder, got: {dbg}"
1950        );
1951    }
1952}