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