Skip to main content

oxicode/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 `~/.oxicode/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 ~/.oxicode/auth.json for consistency with settings)
295    pub fn default_path() -> Option<PathBuf> {
296        dirs::home_dir().map(|p| p.join(".oxicode").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 oxicode-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 = oxicode_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) = oxicode_ai::register_builtins::get_builtin_provider(provider) {
694            let env_key = builtin.env_key;
695            let credentials = self.credentials.read();
696            for other in oxicode_ai::register_builtins::get_builtin_providers() {
697                if other.name == provider {
698                    continue; // 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                         ~/.oxicode/auth.json (mode 0600). For OS-keyring \
978                         support, see the `oxicode-auth-keyring` crate or \
979                         the OXICODE_KEYRING=1 docs at docs/PORT_GUIDE.md."
980                    );
981                });
982
983                if let Err(e) = storage.write(&json) {
984                    tracing::error!("Failed to persist auth storage: {}", e);
985                    self.record_error(e);
986                    return Err("persist failed".to_string());
987                }
988            }
989        }
990        Ok(())
991    }
992
993    // -----------------------------------------------------------------------
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_oxicode`) 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: `Oxicode::create_provider`
1096// consults it at build / switch_model / refresh_credentials time.
1097
1098impl oxicode_sdk::ports::AuthProvider for AuthStorage {
1099    fn get_api_key(
1100        &self,
1101        provider: &str,
1102    ) -> std::pin::Pin<
1103        Box<
1104            dyn std::future::Future<Output = Result<Option<String>, oxicode_sdk::SdkError>>
1105                + Send
1106                + '_,
1107        >,
1108    > {
1109        let result = self.get_api_key(provider);
1110        Box::pin(async move { Ok(result) })
1111    }
1112
1113    /// Sync fast-path — delegates directly to AuthStorage's existing sync
1114    /// `get_api_key`, which already implements the full lookup chain
1115    /// (runtime override → file → OAuth → session → env → fallback).
1116    /// This is the credential source `Oxicode::create_provider` consults.
1117    fn get_api_key_sync(&self, provider: &str) -> Result<Option<String>, oxicode_sdk::SdkError> {
1118        Ok(self.get_api_key(provider))
1119    }
1120
1121    fn set_api_key(
1122        &self,
1123        provider: &str,
1124        key: &str,
1125    ) -> std::pin::Pin<
1126        Box<dyn std::future::Future<Output = Result<(), oxicode_sdk::SdkError>> + Send + '_>,
1127    > {
1128        AuthStorage::set_api_key(self, provider, key.to_string());
1129        Box::pin(async { Ok(()) })
1130    }
1131
1132    fn delete_api_key(
1133        &self,
1134        provider: &str,
1135    ) -> std::pin::Pin<
1136        Box<dyn std::future::Future<Output = Result<(), oxicode_sdk::SdkError>> + Send + '_>,
1137    > {
1138        self.credentials.write().remove(provider);
1139        let _ = self.persist();
1140        Box::pin(async { Ok(()) })
1141    }
1142
1143    fn get_oauth(
1144        &self,
1145        provider: &str,
1146    ) -> std::pin::Pin<
1147        Box<
1148            dyn std::future::Future<
1149                    Output = Result<Option<oxicode_sdk::ports::OAuthToken>, oxicode_sdk::SdkError>,
1150                > + Send
1151                + '_,
1152        >,
1153    > {
1154        let result = self
1155            .get_oauth_credential(provider)
1156            .and_then(|cred| match cred {
1157                AuthCredential::OAuth {
1158                    access_token,
1159                    refresh_token,
1160                    expires_at,
1161                    ..
1162                } => Some(oxicode_sdk::ports::OAuthToken {
1163                    access_token,
1164                    refresh_token,
1165                    expires_at: chrono::DateTime::from_timestamp(expires_at as i64, 0),
1166                    token_type: Some("Bearer".to_string()),
1167                    scope: None,
1168                }),
1169                _ => None,
1170            });
1171        Box::pin(async move { Ok(result) })
1172    }
1173
1174    fn set_oauth(
1175        &self,
1176        provider: &str,
1177        token: oxicode_sdk::ports::OAuthToken,
1178    ) -> std::pin::Pin<
1179        Box<dyn std::future::Future<Output = Result<(), oxicode_sdk::SdkError>> + Send + '_>,
1180    > {
1181        let expires_at = token
1182            .expires_at
1183            .and_then(|dt| dt.timestamp().max(0).try_into().ok())
1184            .unwrap_or(0);
1185        AuthStorage::set_oauth(
1186            self,
1187            provider,
1188            token.access_token,
1189            token.refresh_token,
1190            expires_at,
1191        );
1192        Box::pin(async { Ok(()) })
1193    }
1194
1195    fn list_providers(
1196        &self,
1197    ) -> std::pin::Pin<
1198        Box<
1199            dyn std::future::Future<Output = Result<Vec<String>, oxicode_sdk::SdkError>>
1200                + Send
1201                + '_,
1202        >,
1203    > {
1204        let result = AuthStorage::list_providers(self);
1205        Box::pin(async move { Ok(result) })
1206    }
1207}
1208
1209// ============================================================================
1210// Helper: current unix timestamp
1211// ============================================================================
1212
1213fn now_secs() -> u64 {
1214    std::time::SystemTime::now()
1215        .duration_since(std::time::UNIX_EPOCH)
1216        .map(|d| d.as_secs())
1217        .unwrap_or(0)
1218}
1219
1220// ============================================================================
1221// Keyring Support
1222// ============================================================================
1223//
1224// F-4 (audit 2026-06-21): this module is currently unreachable from the
1225// workspace because the `keyring` feature is not declared in `Cargo.toml`.
1226// It is preserved so a follow-up PR can wire it back in via
1227// `keyring = { version = "2", optional = true }` + `keyring = ["dep:keyring"]`
1228// and add an opt-in env-gated call site in `FileAuthStorage::persist`.
1229// Until that PR lands, the `not(feature = "keyring")` arm always compiles
1230// and the `cfg(feature = "keyring")` arm is dead.
1231
1232/// OS-keyring credential helpers. Currently stubbed because the
1233/// `keyring` cargo feature is not enabled (see F-4 audit note above).
1234#[allow(unexpected_cfgs)]
1235#[deprecated(note = "keyring cargo feature is not wired in Cargo.toml; \
1236    this module is currently a no-op fallback. See docs/PORT_GUIDE.md \
1237    and the F-4 audit note in oxicode-cli/src/store/auth_storage.rs.")]
1238pub mod keyring_support {
1239    use super::*;
1240
1241    /// Try to get a secret from the OS keyring
1242    #[cfg(feature = "keyring")]
1243    pub fn get_keyring_secret(service: &str, account: &str) -> Option<String> {
1244        use keyring::Entry;
1245        Entry::new(service, account)
1246            .ok()
1247            .and_then(|entry| entry.get_password().ok())
1248    }
1249
1250    /// Try to set a secret in the OS keyring
1251    #[cfg(feature = "keyring")]
1252    pub fn set_keyring_secret(service: &str, account: &str, secret: &str) -> AuthResult<()> {
1253        use keyring::Entry;
1254        Entry::new(service, account)
1255            .map_err(|e| AuthError::KeyringError(e.to_string()))?
1256            .set_password(secret)
1257            .map_err(|e| AuthError::KeyringError(e.to_string()))
1258    }
1259
1260    /// Try to delete a secret from the OS keyring
1261    #[cfg(feature = "keyring")]
1262    pub fn delete_keyring_secret(service: &str, account: &str) -> AuthResult<()> {
1263        use keyring::Entry;
1264        Entry::new(service, account)
1265            .map_err(|e| AuthError::KeyringError(e.to_string()))?
1266            .delete_credential()
1267            .map_err(|e| AuthError::KeyringError(e.to_string()))
1268    }
1269
1270    // Non-keyring fallbacks
1271    #[cfg(not(feature = "keyring"))]
1272    /// Retrieve a secret from the OS keyring.
1273    ///
1274    /// Returns `None` when the keyring feature is not compiled in.
1275    pub fn get_keyring_secret(_service: &str, _account: &str) -> Option<String> {
1276        None
1277    }
1278
1279    #[cfg(not(feature = "keyring"))]
1280    /// Store a secret in the OS keyring.
1281    ///
1282    /// Returns an error when the keyring feature is not compiled in.
1283    pub fn set_keyring_secret(_service: &str, _account: &str, _secret: &str) -> AuthResult<()> {
1284        Err(AuthError::KeyringError(
1285            "Keyring support not compiled".to_string(),
1286        ))
1287    }
1288
1289    #[cfg(not(feature = "keyring"))]
1290    /// Delete a secret from the OS keyring.
1291    ///
1292    /// Returns an error when the keyring feature is not compiled in.
1293    pub fn delete_keyring_secret(_service: &str, _account: &str) -> AuthResult<()> {
1294        Err(AuthError::KeyringError(
1295            "Keyring support not compiled".to_string(),
1296        ))
1297    }
1298}
1299
1300// ============================================================================
1301// Singleton
1302// ============================================================================
1303
1304/// Get a shared singleton `Arc<AuthStorage>` instance.
1305///
1306/// Avoids creating multiple `AuthStorage::new()` instances that each
1307/// independently read and cache `auth.json`. All callers share the same
1308/// in-memory state through the `Arc`.
1309pub fn shared_auth_storage() -> Arc<AuthStorage> {
1310    static STORAGE: OnceLock<Arc<AuthStorage>> = OnceLock::new();
1311    STORAGE
1312        .get_or_init(|| {
1313            let storage = Arc::new(AuthStorage::new());
1314            // Default fallback: resolve API keys from environment variables
1315            // using the BuiltinProvider registry (env_key + extra_env_keys).
1316            storage.set_fallback_resolver(Arc::new(EnvVarFallbackResolver));
1317            storage
1318        })
1319        .clone()
1320}
1321
1322// ============================================================================
1323// Tests
1324// ============================================================================
1325
1326#[cfg(test)]
1327mod tests {
1328    use super::*;
1329
1330    #[test]
1331    fn test_auth_storage_new() {
1332        let storage = AuthStorage::in_memory();
1333        assert!(!storage.has("anthropic"));
1334    }
1335
1336    #[test]
1337    fn test_set_and_get_api_key() {
1338        let storage = AuthStorage::in_memory();
1339        storage.set_api_key("anthropic", "sk-test123".to_string());
1340        assert!(storage.has("anthropic"));
1341        assert_eq!(
1342            storage.get_api_key("anthropic"),
1343            Some("sk-test123".to_string())
1344        );
1345    }
1346
1347    #[test]
1348    fn test_runtime_override() {
1349        let storage = AuthStorage::in_memory();
1350        storage.set_api_key("anthropic", "stored-key".to_string());
1351        storage.set_runtime_key("anthropic", "runtime-key".to_string());
1352
1353        // Runtime key should take priority
1354        assert_eq!(
1355            storage.get_api_key("anthropic"),
1356            Some("runtime-key".to_string())
1357        );
1358    }
1359
1360    #[test]
1361    fn test_remove_credential() {
1362        let storage = AuthStorage::in_memory();
1363        storage.set_api_key("anthropic", "sk-test123".to_string());
1364        assert!(storage.has("anthropic"));
1365
1366        storage.remove("anthropic");
1367        assert!(!storage.has("anthropic"));
1368    }
1369
1370    #[test]
1371    fn test_auth_status() {
1372        let storage = AuthStorage::in_memory();
1373        storage.set_api_key("anthropic", "sk-test123".to_string());
1374
1375        let status = storage.get_status("anthropic");
1376        assert!(status.configured);
1377        assert_eq!(status.source, Some("stored".to_string()));
1378        assert_eq!(status.label, Some("api_key".to_string()));
1379    }
1380
1381    #[test]
1382    fn test_auth_status_display() {
1383        let status = AuthStatus {
1384            configured: true,
1385            source: Some("stored".to_string()),
1386            label: Some("api_key".to_string()),
1387        };
1388        let display = format!("{}", status);
1389        assert_eq!(display, "stored (api_key)");
1390
1391        let no_config = AuthStatus {
1392            configured: false,
1393            source: None,
1394            label: None,
1395        };
1396        assert_eq!(format!("{}", no_config), "not configured");
1397    }
1398
1399    #[test]
1400    fn test_list_providers() {
1401        let storage = AuthStorage::in_memory();
1402        storage.set_api_key("anthropic", "key1".to_string());
1403        storage.set_api_key("openai", "key2".to_string());
1404
1405        let providers = storage.list_providers();
1406        assert!(providers.contains(&"anthropic".to_string()));
1407        assert!(providers.contains(&"openai".to_string()));
1408    }
1409
1410    #[test]
1411    fn test_oauth_credential() {
1412        let storage = AuthStorage::in_memory();
1413        storage.set_oauth(
1414            "provider",
1415            "access123".to_string(),
1416            Some("refresh456".to_string()),
1417            u64::MAX,
1418        );
1419
1420        assert!(storage.has("provider"));
1421        assert_eq!(
1422            storage.get_api_key("provider"),
1423            Some("access123".to_string())
1424        );
1425    }
1426
1427    #[test]
1428    fn test_expired_oauth_token() {
1429        let storage = AuthStorage::in_memory();
1430        // Set token that expired in the past
1431        storage.set_oauth("provider", "access123".to_string(), None, 0);
1432
1433        // Token should be treated as expired
1434        let key = storage.get_api_key("provider");
1435        assert!(key.is_none());
1436    }
1437
1438    #[test]
1439    fn test_get_all_credentials() {
1440        let storage = AuthStorage::in_memory();
1441        storage.set_api_key("anthropic", "key1".to_string());
1442        storage.set_api_key("openai", "key2".to_string());
1443
1444        let all = storage.get_all();
1445        assert_eq!(all.len(), 2);
1446    }
1447
1448    #[test]
1449    fn test_clear() {
1450        let storage = AuthStorage::in_memory();
1451        storage.set_api_key("anthropic", "key".to_string());
1452        assert!(storage.has("anthropic"));
1453
1454        storage.clear();
1455        assert!(!storage.has("anthropic"));
1456    }
1457
1458    #[test]
1459    fn test_remove_runtime_key() {
1460        let storage = AuthStorage::in_memory();
1461        storage.set_api_key("anthropic", "stored".to_string());
1462        storage.set_runtime_key("anthropic", "runtime".to_string());
1463
1464        assert_eq!(
1465            storage.get_api_key("anthropic"),
1466            Some("runtime".to_string())
1467        );
1468
1469        storage.remove_runtime_key("anthropic");
1470        assert_eq!(storage.get_api_key("anthropic"), Some("stored".to_string()));
1471    }
1472
1473    #[test]
1474    fn test_auth_credential_is_expired() {
1475        // API key never expires
1476        let api_key_cred = AuthCredential::ApiKey {
1477            key: "test".to_string(),
1478        };
1479        assert!(!api_key_cred.is_expired());
1480
1481        // OAuth token that expires in the future
1482        let future_time = now_secs() + 3600;
1483        let oauth_cred = AuthCredential::OAuth {
1484            access_token: "token".to_string(),
1485            refresh_token: Some("refresh".to_string()),
1486            expires_at: future_time,
1487            scopes: None,
1488            provider_data: None,
1489        };
1490        assert!(!oauth_cred.is_expired());
1491
1492        // OAuth token that expired in the past
1493        let oauth_cred_expired = AuthCredential::OAuth {
1494            access_token: "token".to_string(),
1495            refresh_token: Some("refresh".to_string()),
1496            expires_at: 0,
1497            scopes: None,
1498            provider_data: None,
1499        };
1500        assert!(oauth_cred_expired.is_expired());
1501    }
1502
1503    #[test]
1504    fn test_auth_credential_needs_refresh() {
1505        let future_time = now_secs() + 120; // 2 minutes from now
1506
1507        // Has refresh token, will expire soon - not yet within 60s
1508        let oauth_cred = AuthCredential::OAuth {
1509            access_token: "token".to_string(),
1510            refresh_token: Some("refresh".to_string()),
1511            expires_at: future_time,
1512            scopes: None,
1513            provider_data: None,
1514        };
1515        assert!(!oauth_cred.needs_refresh());
1516
1517        // Within 60 seconds
1518        let soon = now_secs() + 30;
1519        let oauth_soon = AuthCredential::OAuth {
1520            access_token: "token".to_string(),
1521            refresh_token: Some("refresh".to_string()),
1522            expires_at: soon,
1523            scopes: None,
1524            provider_data: None,
1525        };
1526        assert!(oauth_soon.needs_refresh());
1527
1528        // No refresh token - doesn't need refresh
1529        let no_refresh = AuthCredential::OAuth {
1530            access_token: "token".to_string(),
1531            refresh_token: None,
1532            expires_at: future_time,
1533            scopes: None,
1534            provider_data: None,
1535        };
1536        assert!(!no_refresh.needs_refresh());
1537
1538        // API key never needs refresh
1539        let api_key_cred = AuthCredential::ApiKey {
1540            key: "test".to_string(),
1541        };
1542        assert!(!api_key_cred.needs_refresh());
1543    }
1544
1545    #[test]
1546    fn test_auth_credential_access_token() {
1547        let future_time = now_secs() + 3600;
1548
1549        let oauth_cred = AuthCredential::OAuth {
1550            access_token: "valid_token".to_string(),
1551            refresh_token: Some("refresh".to_string()),
1552            expires_at: future_time,
1553            scopes: None,
1554            provider_data: None,
1555        };
1556        assert_eq!(oauth_cred.access_token(), Some("valid_token"));
1557
1558        // Expired token
1559        let expired_cred = AuthCredential::OAuth {
1560            access_token: "expired_token".to_string(),
1561            refresh_token: Some("refresh".to_string()),
1562            expires_at: 0,
1563            scopes: None,
1564            provider_data: None,
1565        };
1566        assert!(expired_cred.access_token().is_none());
1567
1568        // API key returns None via access_token
1569        let api_key_cred = AuthCredential::ApiKey {
1570            key: "api_key_token".to_string(),
1571        };
1572        assert!(api_key_cred.access_token().is_none());
1573    }
1574
1575    #[test]
1576    fn test_get_oauth_credential() {
1577        let storage = AuthStorage::in_memory();
1578        storage.set_oauth(
1579            "provider",
1580            "access".to_string(),
1581            Some("refresh".to_string()),
1582            u64::MAX,
1583        );
1584
1585        let cred = storage.get_oauth_credential("provider");
1586        assert!(cred.is_some());
1587        assert!(matches!(cred.unwrap(), AuthCredential::OAuth { .. }));
1588    }
1589
1590    #[test]
1591    fn test_has_oauth_with_refresh() {
1592        let storage = AuthStorage::in_memory();
1593
1594        // With refresh token
1595        storage.set_oauth(
1596            "with_refresh",
1597            "access".to_string(),
1598            Some("refresh".to_string()),
1599            u64::MAX,
1600        );
1601        assert!(storage.has_oauth_with_refresh("with_refresh"));
1602
1603        // Without refresh token
1604        storage.set_oauth("without_refresh", "access".to_string(), None, u64::MAX);
1605        assert!(!storage.has_oauth_with_refresh("without_refresh"));
1606
1607        // API key provider
1608        storage.set_api_key("apikey_provider", "key".to_string());
1609        assert!(!storage.has_oauth_with_refresh("apikey_provider"));
1610    }
1611
1612    #[test]
1613    fn test_set_oauth_full() {
1614        let storage = AuthStorage::in_memory();
1615        storage.set_oauth_full(
1616            "provider",
1617            "access_token".to_string(),
1618            Some("refresh_token".to_string()),
1619            3600,
1620            Some("read write".to_string()),
1621            Some(serde_json::json!({"extra": "data"})),
1622        );
1623
1624        let cred = storage.get_oauth_credential("provider");
1625        assert!(cred.is_some());
1626        if let AuthCredential::OAuth {
1627            scopes,
1628            provider_data,
1629            ..
1630        } = cred.unwrap()
1631        {
1632            assert_eq!(scopes, Some("read write".to_string()));
1633            assert!(provider_data.is_some());
1634        } else {
1635            panic!("Expected OAuth credential");
1636        }
1637    }
1638
1639    #[test]
1640    fn test_session_token() {
1641        let storage = AuthStorage::in_memory();
1642        storage.set_session(
1643            "browser",
1644            "session-token-123".to_string(),
1645            0, // never expires
1646            Some(serde_json::json!({"user": "test"})),
1647        );
1648
1649        assert!(storage.has("browser"));
1650        assert_eq!(
1651            storage.get_api_key("browser"),
1652            Some("session-token-123".to_string())
1653        );
1654
1655        let cred = storage.get("browser").unwrap();
1656        assert!(matches!(cred, AuthCredential::Session { .. }));
1657        assert!(cred.access_token().is_some());
1658    }
1659
1660    #[test]
1661    fn test_session_token_expired() {
1662        let storage = AuthStorage::in_memory();
1663        storage.set_session("browser", "session-token".to_string(), 1, None);
1664
1665        // Token expired (timestamp 1 is in the past)
1666        assert!(storage.get_api_key("browser").is_none());
1667    }
1668
1669    #[test]
1670    fn test_credential_validation() {
1671        // Valid API key
1672        let valid = AuthCredential::ApiKey {
1673            key: "sk-valid".to_string(),
1674        };
1675        assert!(valid.validate().is_ok());
1676
1677        // Empty API key
1678        let empty = AuthCredential::ApiKey {
1679            key: "".to_string(),
1680        };
1681        assert!(empty.validate().is_err());
1682
1683        // Placeholder
1684        let placeholder = AuthCredential::ApiKey {
1685            key: "your-api-key-here".to_string(),
1686        };
1687        assert!(placeholder.validate().is_err());
1688
1689        // Valid OAuth
1690        let valid_oauth = AuthCredential::OAuth {
1691            access_token: "token".to_string(),
1692            refresh_token: None,
1693            expires_at: now_secs() + 3600,
1694            scopes: None,
1695            provider_data: None,
1696        };
1697        assert!(valid_oauth.validate().is_ok());
1698
1699        // Invalid OAuth (empty token)
1700        let invalid_oauth = AuthCredential::OAuth {
1701            access_token: "".to_string(),
1702            refresh_token: None,
1703            expires_at: 1000,
1704            scopes: None,
1705            provider_data: None,
1706        };
1707        assert!(invalid_oauth.validate().is_err());
1708    }
1709
1710    #[test]
1711    fn test_validate_all() {
1712        let storage = AuthStorage::in_memory();
1713        storage.set_api_key("valid", "sk-good".to_string());
1714        storage.set_api_key("empty", "".to_string());
1715
1716        let errors = storage.validate_all();
1717        assert_eq!(errors.len(), 1);
1718        assert_eq!(errors[0].0, "empty");
1719    }
1720
1721    #[test]
1722    fn test_update_oauth_tokens() {
1723        let storage = AuthStorage::in_memory();
1724        storage.set_oauth(
1725            "provider",
1726            "old-access".to_string(),
1727            Some("old-refresh".to_string()),
1728            now_secs() + 3600,
1729        );
1730
1731        storage
1732            .update_oauth_tokens(
1733                "provider",
1734                "new-access".to_string(),
1735                Some("new-refresh".to_string()),
1736                now_secs() + 7200,
1737            )
1738            .unwrap();
1739
1740        let key = storage.get_api_key("provider");
1741        assert_eq!(key, Some("new-access".to_string()));
1742    }
1743
1744    #[test]
1745    fn test_update_oauth_tokens_wrong_type() {
1746        let storage = AuthStorage::in_memory();
1747        storage.set_api_key("provider", "key".to_string());
1748
1749        let result = storage.update_oauth_tokens(
1750            "provider",
1751            "new-access".to_string(),
1752            None,
1753            now_secs() + 3600,
1754        );
1755        assert!(result.is_err());
1756    }
1757
1758    #[test]
1759    fn test_migrate_provider() {
1760        let storage = AuthStorage::in_memory();
1761        storage.set_api_key("old-provider", "key123".to_string());
1762        storage
1763            .migrate_provider("old-provider", "new-provider")
1764            .unwrap();
1765
1766        assert!(!storage.has("old-provider"));
1767        assert!(storage.has("new-provider"));
1768        assert_eq!(
1769            storage.get_api_key("new-provider"),
1770            Some("key123".to_string())
1771        );
1772    }
1773
1774    #[test]
1775    fn test_migrate_provider_not_found() {
1776        let storage = AuthStorage::in_memory();
1777        let result = storage.migrate_provider("nonexistent", "target");
1778        assert!(result.is_err());
1779    }
1780
1781    #[test]
1782    fn test_error_draining() {
1783        let storage = AuthStorage::in_memory();
1784        let errors = storage.drain_errors();
1785        assert!(errors.is_empty());
1786    }
1787
1788    #[test]
1789    fn test_fallback_resolver() {
1790        let storage = AuthStorage::in_memory();
1791        storage.set_fallback_resolver(Arc::new(FnFallbackResolver::new(Box::new(|provider| {
1792            if provider == "custom" {
1793                Some("custom-key-from-config".to_string())
1794            } else {
1795                None
1796            }
1797        }))));
1798
1799        assert_eq!(
1800            storage.get_api_key("custom"),
1801            Some("custom-key-from-config".to_string())
1802        );
1803        assert!(storage.get_api_key("unknown").is_none());
1804
1805        // Without fallback
1806        storage.clear_fallback_resolver();
1807        assert!(storage.get_api_key("custom").is_none());
1808    }
1809
1810    #[test]
1811    fn test_get_api_key_with_options() {
1812        let storage = AuthStorage::in_memory();
1813        storage.set_fallback_resolver(Arc::new(FnFallbackResolver::new(Box::new(|_| {
1814            Some("fallback-key".to_string())
1815        }))));
1816
1817        // With fallback
1818        assert_eq!(
1819            storage.get_api_key_with_options("test", true),
1820            Some("fallback-key".to_string())
1821        );
1822
1823        // Without fallback
1824        assert!(storage.get_api_key_with_options("test", false).is_none());
1825    }
1826
1827    #[test]
1828    fn test_configured_providers() {
1829        let storage = AuthStorage::in_memory();
1830        storage.set_api_key("openai", "key".to_string());
1831        storage.set_api_key("anthropic", "key".to_string());
1832
1833        let providers = storage.configured_providers();
1834        assert!(providers.len() >= 2);
1835        // Should be sorted
1836        let mut sorted = providers.clone();
1837        sorted.sort();
1838        assert_eq!(providers, sorted);
1839    }
1840
1841    #[test]
1842    fn test_has_multiple_providers() {
1843        let storage = AuthStorage::in_memory();
1844        assert!(!storage.has_multiple_providers());
1845
1846        storage.set_api_key("openai", "key1".to_string());
1847        assert!(!storage.has_multiple_providers());
1848
1849        storage.set_api_key("anthropic", "key2".to_string());
1850        assert!(storage.has_multiple_providers());
1851    }
1852
1853    #[test]
1854    fn test_set_and_get_credential() {
1855        let storage = AuthStorage::in_memory();
1856        let cred = AuthCredential::Session {
1857            token: "abc".to_string(),
1858            expires_at: 0,
1859            metadata: None,
1860        };
1861        storage.set("custom", cred);
1862        let retrieved = storage.get("custom");
1863        assert!(retrieved.is_some());
1864        assert!(matches!(retrieved.unwrap(), AuthCredential::Session { .. }));
1865    }
1866
1867    #[test]
1868    fn test_credential_type_name() {
1869        assert_eq!(
1870            AuthCredential::ApiKey {
1871                key: "k".to_string()
1872            }
1873            .type_name(),
1874            "api_key"
1875        );
1876        assert_eq!(
1877            AuthCredential::OAuth {
1878                access_token: "t".to_string(),
1879                refresh_token: None,
1880                expires_at: 0,
1881                scopes: None,
1882                provider_data: None,
1883            }
1884            .type_name(),
1885            "oauth"
1886        );
1887        assert_eq!(
1888            AuthCredential::Session {
1889                token: "t".to_string(),
1890                expires_at: 0,
1891                metadata: None,
1892            }
1893            .type_name(),
1894            "session"
1895        );
1896    }
1897
1898    // ── F-1 secret masking (code audit 2026-07-25) ──────────────────────
1899
1900    #[test]
1901    fn debug_impl_masks_api_key() {
1902        let cred = AuthCredential::ApiKey {
1903            key: "sk-super-secret-12345".to_string(),
1904        };
1905        let dbg = format!("{cred:?}");
1906        assert!(
1907            !dbg.contains("sk-super-secret-12345"),
1908            "Debug must not leak the API key, got: {dbg}"
1909        );
1910        assert!(
1911            dbg.contains("<redacted>"),
1912            "Debug must show <redacted> placeholder, got: {dbg}"
1913        );
1914    }
1915
1916    #[test]
1917    fn debug_impl_masks_oauth_tokens() {
1918        let cred = AuthCredential::OAuth {
1919            access_token: "ya29.access-token-value".to_string(),
1920            refresh_token: Some("ya29.refresh-token-value".to_string()),
1921            expires_at: 9999999999,
1922            scopes: None,
1923            provider_data: None,
1924        };
1925        let dbg = format!("{cred:?}");
1926        assert!(
1927            !dbg.contains("access-token-value"),
1928            "Debug must not leak the access token, got: {dbg}"
1929        );
1930        assert!(
1931            !dbg.contains("refresh-token-value"),
1932            "Debug must not leak the refresh token, got: {dbg}"
1933        );
1934        // Non-secret fields stay visible for debugging expiry issues.
1935        assert!(
1936            dbg.contains("9999999999"),
1937            "Debug must still show expires_at for diagnostics, got: {dbg}"
1938        );
1939    }
1940
1941    #[test]
1942    fn debug_impl_masks_session_token() {
1943        let cred = AuthCredential::Session {
1944            token: "sess-abc-def-ghi".to_string(),
1945            expires_at: 0,
1946            metadata: None,
1947        };
1948        let dbg = format!("{cred:?}");
1949        assert!(
1950            !dbg.contains("sess-abc-def-ghi"),
1951            "Debug must not leak the session token, got: {dbg}"
1952        );
1953        assert!(
1954            dbg.contains("<redacted>"),
1955            "Debug must show <redacted> placeholder, got: {dbg}"
1956        );
1957    }
1958}