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
280///
281/// Writes always target `path` (the canonical home). `legacy_read_path` is a
282/// read-only fallback (`~/.oxicode/auth.json` on pre-unified-layout installs)
283/// consulted only while the canonical file does not exist yet.
284pub struct FileAuthStorage {
285    path: PathBuf,
286    /// Read-only legacy fallback; `None` when absent or when an explicit
287    /// `$OXICODE_HOME` override opts out of legacy merging.
288    legacy_read_path: Option<PathBuf>,
289    cache: RwLock<Option<String>>,
290}
291
292impl FileAuthStorage {
293    /// Create a new file-based auth storage
294    pub fn new(path: PathBuf) -> Self {
295        Self {
296            path,
297            legacy_read_path: None,
298            cache: RwLock::new(None),
299        }
300    }
301
302    /// Create the default file backend with legacy read fallback wired in.
303    ///
304    /// `None` only when no home can be resolved at all.
305    pub fn with_default_paths() -> Option<Self> {
306        Some(Self::with_legacy_fallback(
307            Self::default_path()?,
308            Self::legacy_default_path(),
309        ))
310    }
311
312    /// Create a file backend with an explicit legacy read fallback
313    /// (canonical-first reads; testable without touching the environment).
314    pub fn with_legacy_fallback(path: PathBuf, legacy_read_path: Option<PathBuf>) -> Self {
315        Self {
316            path,
317            legacy_read_path,
318            cache: RwLock::new(None),
319        }
320    }
321
322    /// Canonical write target: `<oxicode_home>/auth.json`.
323    pub fn default_path() -> Option<PathBuf> {
324        oxicode_catalog::oxi_home::oxicode_home().map(|h| h.join("auth.json"))
325    }
326
327    /// Legacy read-only path: `<legacy_home>/auth.json` when the legacy home
328    /// exists; `None` otherwise (including under an explicit `$OXICODE_HOME`).
329    pub fn legacy_default_path() -> Option<PathBuf> {
330        oxicode_catalog::oxi_home::legacy_home_dir().map(|h| h.join("auth.json"))
331    }
332
333    /// Get the storage path
334    pub fn path(&self) -> &PathBuf {
335        &self.path
336    }
337}
338
339impl AuthStorageBackend for FileAuthStorage {
340    fn read(&self) -> AuthResult<Option<String>> {
341        // Canonical-first; fall back to the read-only legacy file only while
342        // the canonical file does not exist yet (pre-migration installs).
343        let read_path = if self.path.exists() {
344            &self.path
345        } else if let Some(legacy) = self.legacy_read_path.as_ref().filter(|p| p.exists()) {
346            legacy
347        } else {
348            return Ok(None);
349        };
350
351        match std::fs::read_to_string(read_path) {
352            Ok(content) => {
353                *self.cache.write() = Some(content.clone());
354                Ok(Some(content))
355            }
356            Err(e) => Err(AuthError::ReadError(e.to_string())),
357        }
358    }
359
360    fn write(&self, data: &str) -> AuthResult<()> {
361        // Ensure parent directory exists with restricted permissions
362        if let Some(parent) = self.path.parent() {
363            std::fs::create_dir_all(parent).map_err(|e| AuthError::WriteError(e.to_string()))?;
364            #[cfg(unix)]
365            {
366                use std::os::unix::fs::PermissionsExt;
367                let perms = std::fs::Permissions::from_mode(0o700);
368                let _ = std::fs::set_permissions(parent, perms);
369            }
370        }
371
372        // Write the file
373        std::fs::write(&self.path, data).map_err(|e| AuthError::WriteError(e.to_string()))?;
374
375        // Set file permissions to owner-only on Unix
376        #[cfg(unix)]
377        {
378            use std::os::unix::fs::PermissionsExt;
379            let perms = std::fs::Permissions::from_mode(0o600);
380            std::fs::set_permissions(&self.path, perms)
381                .map_err(|e| AuthError::WriteError(e.to_string()))?;
382        }
383
384        *self.cache.write() = Some(data.to_string());
385        Ok(())
386    }
387
388    fn delete(&self) -> AuthResult<()> {
389        if self.path.exists() {
390            std::fs::remove_file(&self.path).map_err(|e| AuthError::WriteError(e.to_string()))?;
391        }
392        *self.cache.write() = None;
393        Ok(())
394    }
395}
396
397// ============================================================================
398// Memory Backend
399// ============================================================================
400
401/// Memory-based auth storage (for testing)
402pub struct MemoryAuthStorage {
403    data: RwLock<HashMap<String, AuthCredential>>,
404}
405
406impl MemoryAuthStorage {
407    /// Create a new memory auth storage
408    pub fn new() -> Self {
409        Self {
410            data: RwLock::new(HashMap::new()),
411        }
412    }
413}
414
415impl Default for MemoryAuthStorage {
416    fn default() -> Self {
417        Self::new()
418    }
419}
420
421impl AuthStorageBackend for MemoryAuthStorage {
422    fn read(&self) -> AuthResult<Option<String>> {
423        // Memory backend doesn't use JSON serialization
424        Ok(None)
425    }
426
427    fn write(&self, _data: &str) -> AuthResult<()> {
428        Ok(())
429    }
430
431    fn delete(&self) -> AuthResult<()> {
432        self.data.write().clear();
433        Ok(())
434    }
435}
436
437// ============================================================================
438// Fallback Resolver
439// ============================================================================
440
441/// Trait for fallback API key resolution (e.g., from models.json config)
442pub trait FallbackResolver: Send + Sync {
443    /// Try to resolve an API key for the given provider
444    fn resolve(&self, provider: &str) -> Option<String>;
445}
446
447/// A simple closure-based fallback resolver
448pub struct FnFallbackResolver {
449    #[allow(clippy::type_complexity)]
450    f: Box<dyn Fn(&str) -> Option<String> + Send + Sync>,
451}
452
453impl FnFallbackResolver {
454    /// Create from a closure
455    #[allow(clippy::type_complexity)]
456    pub fn new(f: Box<dyn Fn(&str) -> Option<String> + Send + Sync>) -> Self {
457        Self { f }
458    }
459}
460
461impl FallbackResolver for FnFallbackResolver {
462    fn resolve(&self, provider: &str) -> Option<String> {
463        (self.f)(provider)
464    }
465}
466
467/// Environment variable fallback resolver.
468///
469/// Uses oxicode-ai's `BuiltinProvider` registry to look up the primary and
470/// extra env var names for each provider, then checks `std::env`.
471pub struct EnvVarFallbackResolver;
472
473impl FallbackResolver for EnvVarFallbackResolver {
474    fn resolve(&self, provider: &str) -> Option<String> {
475        // Look up the provider's env key from the builtin registry
476        let builtin = oxicode_ai::get_builtin_provider(provider)?;
477        let key = builtin.env_key;
478
479        // Try primary key
480        if let Ok(val) = std::env::var(key)
481            && !val.is_empty()
482        {
483            return Some(val);
484        }
485
486        // Try extra keys
487        for extra in builtin.extra_env_keys {
488            if let Ok(val) = std::env::var(extra)
489                && !val.is_empty()
490            {
491                return Some(val);
492            }
493        }
494
495        None
496    }
497}
498
499// ============================================================================
500// Auth Storage (Main)
501// ============================================================================
502
503/// Main auth storage struct.
504///
505/// Provides multi-layered credential lookup with the following priority:
506/// 1. Runtime override (CLI --api-key)
507/// 2. Stored API key from auth.json
508/// 3. OAuth token from auth.json (with auto-refresh awareness)
509/// 4. Session token from auth.json
510/// 5. Environment variable
511/// 6. Fallback resolver (e.g., custom provider config from models.json)
512pub struct AuthStorage {
513    /// File-based storage backend
514    file_storage: Option<Arc<dyn AuthStorageBackend>>,
515    /// In-memory credential cache
516    credentials: RwLock<HashMap<String, AuthCredential>>,
517    /// Runtime overrides (CLI --api-key)
518    runtime_overrides: RwLock<HashMap<String, String>>,
519    /// Fallback resolver for custom providers
520    fallback_resolver: RwLock<Option<Arc<dyn FallbackResolver>>>,
521    /// Collected errors
522    errors: RwLock<Vec<AuthError>>,
523    /// Whether initial load had an error
524    load_error: RwLock<Option<AuthError>>,
525    /// OnceLock to warn about plaintext storage only once
526    plaintext_warned: OnceLock<()>,
527}
528
529impl AuthStorage {
530    /// Create a new auth storage with default file backend
531    pub fn new() -> Self {
532        let file_storage = FileAuthStorage::with_default_paths()
533            .map(|s| Arc::new(s) as Arc<dyn AuthStorageBackend>);
534
535        let credentials = if let Some(ref storage) = file_storage {
536            match storage.read() {
537                Ok(Some(content)) => serde_json::from_str(&content).unwrap_or_default(),
538                _ => HashMap::new(),
539            }
540        } else {
541            HashMap::new()
542        };
543
544        Self {
545            file_storage,
546            credentials: RwLock::new(credentials),
547            runtime_overrides: RwLock::new(HashMap::new()),
548            fallback_resolver: RwLock::new(None),
549            errors: RwLock::new(Vec::new()),
550            load_error: RwLock::new(None),
551            plaintext_warned: OnceLock::new(),
552        }
553    }
554
555    /// Create with explicit storage backend
556    pub fn with_backend(backend: impl AuthStorageBackend + 'static) -> Self {
557        let credentials = match backend.read() {
558            Ok(Some(content)) => serde_json::from_str(&content).unwrap_or_default(),
559            _ => HashMap::new(),
560        };
561
562        Self {
563            file_storage: Some(Arc::new(backend)),
564            credentials: RwLock::new(credentials),
565            runtime_overrides: RwLock::new(HashMap::new()),
566            fallback_resolver: RwLock::new(None),
567            errors: RwLock::new(Vec::new()),
568            load_error: RwLock::new(None),
569            plaintext_warned: OnceLock::new(),
570        }
571    }
572
573    /// Create a memory-only storage (for testing)
574    pub fn in_memory() -> Self {
575        Self {
576            file_storage: None,
577            credentials: RwLock::new(HashMap::new()),
578            runtime_overrides: RwLock::new(HashMap::new()),
579            fallback_resolver: RwLock::new(None),
580            errors: RwLock::new(Vec::new()),
581            load_error: RwLock::new(None),
582            plaintext_warned: OnceLock::new(),
583        }
584    }
585
586    /// Get the default auth file path
587    pub fn default_path() -> Option<PathBuf> {
588        FileAuthStorage::default_path()
589    }
590
591    // -----------------------------------------------------------------------
592    // Runtime overrides
593    // -----------------------------------------------------------------------
594
595    /// Set a runtime API key override (from CLI --api-key)
596    pub fn set_runtime_key(&self, provider: &str, api_key: String) {
597        self.runtime_overrides
598            .write()
599            .insert(provider.to_string(), api_key);
600    }
601
602    /// Remove a runtime override
603    pub fn remove_runtime_key(&self, provider: &str) {
604        self.runtime_overrides.write().remove(provider);
605    }
606
607    // -----------------------------------------------------------------------
608    // Fallback resolver
609    // -----------------------------------------------------------------------
610
611    /// Set a fallback resolver for API keys not found in auth.json or env vars.
612    /// Used for custom provider keys from models.json.
613    pub fn set_fallback_resolver(&self, resolver: Arc<dyn FallbackResolver>) {
614        *self.fallback_resolver.write() = Some(resolver);
615    }
616
617    /// Clear the fallback resolver
618    pub fn clear_fallback_resolver(&self) {
619        *self.fallback_resolver.write() = None;
620    }
621
622    // -----------------------------------------------------------------------
623    // Credential query
624    // -----------------------------------------------------------------------
625
626    /// Check if a provider has any auth configured
627    pub fn has_auth(&self, provider: &str) -> bool {
628        if self.runtime_overrides.read().contains_key(provider) {
629            return true;
630        }
631        if self.credentials.read().contains_key(provider) {
632            return true;
633        }
634        if let Some(ref resolver) = *self.fallback_resolver.read()
635            && resolver.resolve(provider).is_some()
636        {
637            return true;
638        }
639        false
640    }
641
642    /// Get auth status for a provider (without exposing credentials)
643    pub fn get_status(&self, provider: &str) -> AuthStatus {
644        if self.runtime_overrides.read().contains_key(provider) {
645            return AuthStatus {
646                configured: false,
647                source: Some("runtime".to_string()),
648                label: Some("--api-key".to_string()),
649            };
650        }
651
652        if let Some(cred) = self.credentials.read().get(provider) {
653            return AuthStatus {
654                configured: true,
655                source: Some("stored".to_string()),
656                label: Some(cred.type_name().to_string()),
657            };
658        }
659
660        if let Some(ref resolver) = *self.fallback_resolver.read()
661            && resolver.resolve(provider).is_some()
662        {
663            return AuthStatus {
664                configured: false,
665                source: Some("fallback".to_string()),
666                label: Some("custom provider config".to_string()),
667            };
668        }
669
670        AuthStatus {
671            configured: false,
672            source: None,
673            label: None,
674        }
675    }
676
677    /// Get API key for a provider.
678    ///
679    /// Priority:
680    /// 1. Runtime override (CLI --api-key)
681    /// 2. Stored API key from auth.json
682    /// 3. OAuth token from auth.json (auto-refreshed)
683    /// 4. Session token from auth.json
684    /// 5. Fallback resolver
685    pub fn get_api_key(&self, provider: &str) -> Option<String> {
686        self.get_api_key_with_options(provider, true)
687    }
688
689    /// Get API key with option to include/exclude fallback resolver
690    pub fn get_api_key_with_options(
691        &self,
692        provider: &str,
693        include_fallback: bool,
694    ) -> Option<String> {
695        // 1. Runtime override
696        if let Some(key) = self.runtime_overrides.read().get(provider) {
697            return Some(key.clone());
698        }
699
700        // 2-4. Stored credential
701        if let Some(cred) = self.credentials.read().get(provider) {
702            return match cred {
703                AuthCredential::ApiKey { key } => Some(key.clone()),
704                AuthCredential::OAuth {
705                    access_token,
706                    expires_at,
707                    ..
708                } => {
709                    if *expires_at > now_secs() {
710                        Some(access_token.clone())
711                    } else {
712                        // Token expired - caller should handle refresh
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        // 5. Cross-provider alias lookup:
729        //    If the key is stored under a different provider name that shares
730        //    the same env_key (e.g. key stored as "zai-coding-global" but
731        //    looked up as "zai"), check those providers' stored credentials.
732        if let Some(builtin) = oxicode_ai::register_builtins::get_builtin_provider(provider) {
733            let env_key = builtin.env_key;
734            let credentials = self.credentials.read();
735            for other in oxicode_ai::register_builtins::get_builtin_providers() {
736                if other.name == provider {
737                    continue; // already checked above
738                }
739                if other.env_key == env_key
740                    && let Some(cred) = credentials.get(other.name)
741                {
742                    return match cred {
743                        AuthCredential::ApiKey { key } => Some(key.clone()),
744                        AuthCredential::OAuth {
745                            access_token,
746                            expires_at,
747                            ..
748                        } => {
749                            if *expires_at > now_secs() {
750                                Some(access_token.clone())
751                            } else {
752                                None
753                            }
754                        }
755                        AuthCredential::Session {
756                            token, expires_at, ..
757                        } => {
758                            if *expires_at == 0 || *expires_at > now_secs() {
759                                Some(token.clone())
760                            } else {
761                                None
762                            }
763                        }
764                    };
765                }
766            }
767        }
768
769        // 6. Fallback resolver
770        if include_fallback && let Some(ref resolver) = *self.fallback_resolver.read() {
771            return resolver.resolve(provider);
772        }
773
774        None
775    }
776
777    // -----------------------------------------------------------------------
778    // Credential mutation
779    // -----------------------------------------------------------------------
780
781    /// Set API key for a provider
782    pub fn set_api_key(&self, provider: &str, key: String) {
783        self.credentials
784            .write()
785            .insert(provider.to_string(), AuthCredential::ApiKey { key });
786        if let Err(e) = self.persist() {
787            tracing::warn!("Failed to persist API key for '{}': {}", provider, e);
788        }
789    }
790
791    /// Set OAuth credential for a provider
792    pub fn set_oauth(
793        &self,
794        provider: &str,
795        access_token: String,
796        refresh_token: Option<String>,
797        expires_at: u64,
798    ) {
799        self.set_oauth_full(
800            provider,
801            access_token,
802            refresh_token,
803            expires_at,
804            None,
805            None,
806        );
807    }
808
809    /// Set OAuth credential with full details
810    pub fn set_oauth_full(
811        &self,
812        provider: &str,
813        access_token: String,
814        refresh_token: Option<String>,
815        expires_at: u64,
816        scopes: Option<String>,
817        provider_data: Option<serde_json::Value>,
818    ) {
819        self.credentials.write().insert(
820            provider.to_string(),
821            AuthCredential::OAuth {
822                access_token,
823                refresh_token,
824                expires_at,
825                scopes,
826                provider_data,
827            },
828        );
829        if let Err(e) = self.persist() {
830            tracing::warn!("Failed to persist OAuth token for '{}': {}", provider, e);
831        }
832    }
833
834    /// Set session token for a provider
835    pub fn set_session(
836        &self,
837        provider: &str,
838        token: String,
839        expires_at: u64,
840        metadata: Option<serde_json::Value>,
841    ) {
842        self.credentials.write().insert(
843            provider.to_string(),
844            AuthCredential::Session {
845                token,
846                expires_at,
847                metadata,
848            },
849        );
850        if let Err(e) = self.persist() {
851            tracing::warn!("Failed to persist session for '{}': {}", provider, e);
852        }
853    }
854
855    /// Update an existing OAuth credential (for token refresh)
856    pub fn update_oauth_tokens(
857        &self,
858        provider: &str,
859        new_access_token: String,
860        new_refresh_token: Option<String>,
861        new_expires_at: u64,
862    ) -> AuthResult<()> {
863        let mut creds = self.credentials.write();
864        let cred = creds
865            .get_mut(provider)
866            .ok_or_else(|| AuthError::NotFound(provider.to_string()))?;
867
868        match cred {
869            AuthCredential::OAuth {
870                access_token,
871                refresh_token,
872                expires_at,
873                ..
874            } => {
875                *access_token = new_access_token;
876                *refresh_token = new_refresh_token;
877                *expires_at = new_expires_at;
878            }
879            _ => {
880                return Err(AuthError::InvalidFormat(format!(
881                    "Provider '{}' does not have OAuth credentials",
882                    provider
883                )));
884            }
885        }
886
887        drop(creds);
888        if let Err(e) = self.persist() {
889            tracing::warn!(
890                "Failed to persist OAuth token update for '{}': {}",
891                provider,
892                e
893            );
894        }
895        Ok(())
896    }
897
898    // -----------------------------------------------------------------------
899    // Credential retrieval
900    // -----------------------------------------------------------------------
901
902    /// Get credential for a provider
903    pub fn get(&self, provider: &str) -> Option<AuthCredential> {
904        self.credentials.read().get(provider).cloned()
905    }
906
907    /// Get OAuth credential for a provider (for token refresh)
908    pub fn get_oauth_credential(&self, provider: &str) -> Option<AuthCredential> {
909        self.credentials.read().get(provider).cloned()
910    }
911
912    /// Check if a provider has OAuth credentials that can be refreshed
913    pub fn has_oauth_with_refresh(&self, provider: &str) -> bool {
914        if let Some(cred) = self.credentials.read().get(provider) {
915            matches!(
916                cred,
917                AuthCredential::OAuth {
918                    refresh_token: Some(_),
919                    ..
920                }
921            )
922        } else {
923            false
924        }
925    }
926
927    // -----------------------------------------------------------------------
928    // CRUD operations
929    // -----------------------------------------------------------------------
930
931    /// Set a credential for a provider
932    pub fn set(&self, provider: &str, credential: AuthCredential) {
933        self.credentials
934            .write()
935            .insert(provider.to_string(), credential);
936        if let Err(e) = self.persist() {
937            tracing::warn!("Failed to persist credential for '{}': {}", provider, e);
938        }
939    }
940
941    /// Remove credential for a provider
942    pub fn remove(&self, provider: &str) {
943        self.credentials.write().remove(provider);
944        if let Err(e) = self.persist() {
945            tracing::warn!("Failed to persist after removing '{}': {}", provider, e);
946        }
947    }
948
949    /// List all providers with credentials
950    pub fn list_providers(&self) -> Vec<String> {
951        self.credentials.read().keys().cloned().collect()
952    }
953
954    /// Check if credential exists for provider in storage
955    pub fn has(&self, provider: &str) -> bool {
956        self.credentials.read().contains_key(provider)
957    }
958
959    /// Get all credentials
960    pub fn get_all(&self) -> HashMap<String, AuthCredential> {
961        self.credentials.read().clone()
962    }
963    /// Clear all stored credentials
964    pub fn clear(&self) {
965        self.credentials.write().clear();
966        if let Err(e) = self.persist() {
967            tracing::warn!("Failed to persist after clearing credentials: {}", e);
968        }
969    }
970
971    // -----------------------------------------------------------------------
972    // Persistence
973    // -----------------------------------------------------------------------
974
975    /// Reload from disk
976    pub fn reload(&self) {
977        if let Some(ref storage) = self.file_storage {
978            match storage.read() {
979                Ok(Some(content)) => {
980                    if let Ok(creds) = serde_json::from_str(&content) {
981                        *self.credentials.write() = creds;
982                    }
983                    *self.load_error.write() = None;
984                }
985                Ok(None) => {
986                    self.credentials.write().clear();
987                    *self.load_error.write() = None;
988                }
989                Err(e) => {
990                    *self.load_error.write() = Some(e);
991                    self.record_error(AuthError::ReadError(
992                        "Failed to reload auth storage".to_string(),
993                    ));
994                }
995            }
996        }
997    }
998
999    /// Persist to disk
1000    #[allow(unexpected_cfgs)]
1001    fn persist(&self) -> Result<(), String> {
1002        if let Some(ref storage) = self.file_storage {
1003            let creds = self.credentials.read();
1004            if let Ok(json) = serde_json::to_string_pretty(&*creds) {
1005                // F-4 (audit 2026-06-21): the previous warning suggested
1006                // enabling a `keyring` feature that is never declared in
1007                // `Cargo.toml` (the `keyring_support` module at line 1047
1008                // is dead code: `#[cfg(feature = "keyring")]` is never
1009                // selected, so `cargo` always builds the `not(feature)`
1010                // branch). Replace with an accurate one-shot warning that
1011                // names the actual on-disk path and points at the docs.
1012                self.plaintext_warned.get_or_init(|| {
1013                    tracing::warn!(
1014                        "Auth credentials are stored in plaintext at \
1015                         ~/.oxicode/auth.json (mode 0600). For OS-keyring \
1016                         support, see the `oxicode-auth-keyring` crate or \
1017                         the OXICODE_KEYRING=1 docs at docs/PORT_GUIDE.md."
1018                    );
1019                });
1020
1021                if let Err(e) = storage.write(&json) {
1022                    tracing::error!("Failed to persist auth storage: {}", e);
1023                    self.record_error(e);
1024                    return Err("persist failed".to_string());
1025                }
1026            }
1027        }
1028        Ok(())
1029    }
1030
1031    // -----------------------------------------------------------------------
1032    // Error tracking
1033    // -----------------------------------------------------------------------
1034
1035    /// Record an error
1036    fn record_error(&self, error: AuthError) {
1037        self.errors.write().push(error);
1038    }
1039
1040    /// Drain collected errors
1041    pub fn drain_errors(&self) -> Vec<AuthError> {
1042        let mut errors = self.errors.write();
1043        std::mem::take(&mut *errors)
1044    }
1045
1046    /// Get the last load error
1047    pub fn load_error(&self) -> Option<AuthError> {
1048        self.load_error.read().clone()
1049    }
1050
1051    // -----------------------------------------------------------------------
1052    // Validation
1053    // -----------------------------------------------------------------------
1054
1055    /// Validate all stored credentials
1056    pub fn validate_all(&self) -> Vec<(String, CredentialValidationError)> {
1057        let creds = self.credentials.read();
1058        let mut results = Vec::new();
1059        for (provider, cred) in creds.iter() {
1060            if let Err(e) = cred.validate() {
1061                results.push((provider.clone(), e));
1062            }
1063        }
1064        results
1065    }
1066
1067    /// Validate credential for a specific provider
1068    pub fn validate(&self, provider: &str) -> Result<(), CredentialValidationError> {
1069        let creds = self.credentials.read();
1070        let cred = creds.get(provider).ok_or_else(|| {
1071            CredentialValidationError::EmptyField(format!(
1072                "no credential for provider '{}'",
1073                provider
1074            ))
1075        })?;
1076        cred.validate()
1077    }
1078
1079    // -----------------------------------------------------------------------
1080    // Multi-provider support
1081    // -----------------------------------------------------------------------
1082
1083    /// Get all configured provider IDs (sorted)
1084    pub fn configured_providers(&self) -> Vec<String> {
1085        let mut providers: Vec<String> = self.credentials.read().keys().cloned().collect();
1086        providers.sort();
1087        providers
1088    }
1089
1090    /// Check if multiple providers are configured
1091    pub fn has_multiple_providers(&self) -> bool {
1092        self.credentials.read().len() > 1
1093    }
1094
1095    /// Get the primary provider (first configured, preferring stored over env)
1096    pub fn primary_provider(&self) -> Option<String> {
1097        let creds = self.credentials.read();
1098        creds.keys().next().cloned()
1099    }
1100
1101    /// Migrate credentials from one provider to another
1102    pub fn migrate_provider(&self, from: &str, to: &str) -> AuthResult<()> {
1103        let mut creds = self.credentials.write();
1104        let cred = creds
1105            .remove(from)
1106            .ok_or_else(|| AuthError::NotFound(from.to_string()))?;
1107        creds.insert(to.to_string(), cred);
1108        drop(creds);
1109        let _ = self.persist();
1110        Ok(())
1111    }
1112}
1113
1114impl Default for AuthStorage {
1115    fn default() -> Self {
1116        Self::new()
1117    }
1118}
1119
1120// ============================================================================
1121// SDK AuthProvider port adapter
1122// ============================================================================
1123//
1124// AuthStorage is the CLI's actual credential store: every TUI overlay
1125// (provider_select.rs, slash/builtin/provider.rs, …) writes here via
1126// `shared_auth_storage()`. Registering it DIRECTLY as the SDK `AuthProvider`
1127// port (replacing FileAuthProvider in `build_oxicode`) eliminates both the
1128// dual-cache problem (the port IS what overlays write to) and the schema
1129// mismatch (FileAuthProvider's `{version,providers:{name:{api_key}}}`
1130// format can't deserialize AuthStorage's `#[serde(tag="type")]` enum
1131// output — see issue #40 advisor follow-up).
1132//
1133// The sync fast-path is the load-bearing piece: `Oxicode::create_provider`
1134// consults it at build / switch_model / refresh_credentials time.
1135
1136impl oxicode_sdk::ports::AuthProvider for AuthStorage {
1137    fn get_api_key(
1138        &self,
1139        provider: &str,
1140    ) -> std::pin::Pin<
1141        Box<
1142            dyn std::future::Future<Output = Result<Option<String>, oxicode_sdk::SdkError>>
1143                + Send
1144                + '_,
1145        >,
1146    > {
1147        let result = self.get_api_key(provider);
1148        Box::pin(async move { Ok(result) })
1149    }
1150
1151    /// Sync fast-path — delegates directly to AuthStorage's existing sync
1152    /// `get_api_key`, which already implements the full lookup chain
1153    /// (runtime override → file → OAuth → session → env → fallback).
1154    /// This is the credential source `Oxicode::create_provider` consults.
1155    fn get_api_key_sync(&self, provider: &str) -> Result<Option<String>, oxicode_sdk::SdkError> {
1156        Ok(self.get_api_key(provider))
1157    }
1158
1159    fn set_api_key(
1160        &self,
1161        provider: &str,
1162        key: &str,
1163    ) -> std::pin::Pin<
1164        Box<dyn std::future::Future<Output = Result<(), oxicode_sdk::SdkError>> + Send + '_>,
1165    > {
1166        AuthStorage::set_api_key(self, provider, key.to_string());
1167        Box::pin(async { Ok(()) })
1168    }
1169
1170    fn delete_api_key(
1171        &self,
1172        provider: &str,
1173    ) -> std::pin::Pin<
1174        Box<dyn std::future::Future<Output = Result<(), oxicode_sdk::SdkError>> + Send + '_>,
1175    > {
1176        self.credentials.write().remove(provider);
1177        let _ = self.persist();
1178        Box::pin(async { Ok(()) })
1179    }
1180
1181    fn get_oauth(
1182        &self,
1183        provider: &str,
1184    ) -> std::pin::Pin<
1185        Box<
1186            dyn std::future::Future<
1187                    Output = Result<Option<oxicode_sdk::ports::OAuthToken>, oxicode_sdk::SdkError>,
1188                > + Send
1189                + '_,
1190        >,
1191    > {
1192        let result = self
1193            .get_oauth_credential(provider)
1194            .and_then(|cred| match cred {
1195                AuthCredential::OAuth {
1196                    access_token,
1197                    refresh_token,
1198                    expires_at,
1199                    ..
1200                } => Some(oxicode_sdk::ports::OAuthToken {
1201                    access_token,
1202                    refresh_token,
1203                    expires_at: chrono::DateTime::from_timestamp(expires_at as i64, 0),
1204                    token_type: Some("Bearer".to_string()),
1205                    scope: None,
1206                }),
1207                _ => None,
1208            });
1209        Box::pin(async move { Ok(result) })
1210    }
1211
1212    fn set_oauth(
1213        &self,
1214        provider: &str,
1215        token: oxicode_sdk::ports::OAuthToken,
1216    ) -> std::pin::Pin<
1217        Box<dyn std::future::Future<Output = Result<(), oxicode_sdk::SdkError>> + Send + '_>,
1218    > {
1219        let expires_at = token
1220            .expires_at
1221            .and_then(|dt| dt.timestamp().max(0).try_into().ok())
1222            .unwrap_or(0);
1223        AuthStorage::set_oauth(
1224            self,
1225            provider,
1226            token.access_token,
1227            token.refresh_token,
1228            expires_at,
1229        );
1230        Box::pin(async { Ok(()) })
1231    }
1232
1233    fn list_providers(
1234        &self,
1235    ) -> std::pin::Pin<
1236        Box<
1237            dyn std::future::Future<Output = Result<Vec<String>, oxicode_sdk::SdkError>>
1238                + Send
1239                + '_,
1240        >,
1241    > {
1242        let result = AuthStorage::list_providers(self);
1243        Box::pin(async move { Ok(result) })
1244    }
1245}
1246
1247// ============================================================================
1248// Helper: current unix timestamp
1249// ============================================================================
1250
1251fn now_secs() -> u64 {
1252    std::time::SystemTime::now()
1253        .duration_since(std::time::UNIX_EPOCH)
1254        .map(|d| d.as_secs())
1255        .unwrap_or(0)
1256}
1257
1258// ============================================================================
1259// Keyring Support
1260// ============================================================================
1261//
1262// F-4 (audit 2026-06-21): this module is currently unreachable from the
1263// workspace because the `keyring` feature is not declared in `Cargo.toml`.
1264// It is preserved so a follow-up PR can wire it back in via
1265// `keyring = { version = "2", optional = true }` + `keyring = ["dep:keyring"]`
1266// and add an opt-in env-gated call site in `FileAuthStorage::persist`.
1267// Until that PR lands, the `not(feature = "keyring")` arm always compiles
1268// and the `cfg(feature = "keyring")` arm is dead.
1269
1270/// OS-keyring credential helpers. Currently stubbed because the
1271/// `keyring` cargo feature is not enabled (see F-4 audit note above).
1272#[allow(unexpected_cfgs)]
1273#[deprecated(note = "keyring cargo feature is not wired in Cargo.toml; \
1274    this module is currently a no-op fallback. See docs/PORT_GUIDE.md \
1275    and the F-4 audit note in oxicode-cli/src/store/auth_storage.rs.")]
1276pub mod keyring_support {
1277    use super::*;
1278
1279    /// Try to get a secret from the OS keyring
1280    #[cfg(feature = "keyring")]
1281    pub fn get_keyring_secret(service: &str, account: &str) -> Option<String> {
1282        use keyring::Entry;
1283        Entry::new(service, account)
1284            .ok()
1285            .and_then(|entry| entry.get_password().ok())
1286    }
1287
1288    /// Try to set a secret in the OS keyring
1289    #[cfg(feature = "keyring")]
1290    pub fn set_keyring_secret(service: &str, account: &str, secret: &str) -> AuthResult<()> {
1291        use keyring::Entry;
1292        Entry::new(service, account)
1293            .map_err(|e| AuthError::KeyringError(e.to_string()))?
1294            .set_password(secret)
1295            .map_err(|e| AuthError::KeyringError(e.to_string()))
1296    }
1297
1298    /// Try to delete a secret from the OS keyring
1299    #[cfg(feature = "keyring")]
1300    pub fn delete_keyring_secret(service: &str, account: &str) -> AuthResult<()> {
1301        use keyring::Entry;
1302        Entry::new(service, account)
1303            .map_err(|e| AuthError::KeyringError(e.to_string()))?
1304            .delete_credential()
1305            .map_err(|e| AuthError::KeyringError(e.to_string()))
1306    }
1307
1308    // Non-keyring fallbacks
1309    #[cfg(not(feature = "keyring"))]
1310    /// Retrieve a secret from the OS keyring.
1311    ///
1312    /// Returns `None` when the keyring feature is not compiled in.
1313    pub fn get_keyring_secret(_service: &str, _account: &str) -> Option<String> {
1314        None
1315    }
1316
1317    #[cfg(not(feature = "keyring"))]
1318    /// Store a secret in the OS keyring.
1319    ///
1320    /// Returns an error when the keyring feature is not compiled in.
1321    pub fn set_keyring_secret(_service: &str, _account: &str, _secret: &str) -> AuthResult<()> {
1322        Err(AuthError::KeyringError(
1323            "Keyring support not compiled".to_string(),
1324        ))
1325    }
1326
1327    #[cfg(not(feature = "keyring"))]
1328    /// Delete a secret from the OS keyring.
1329    ///
1330    /// Returns an error when the keyring feature is not compiled in.
1331    pub fn delete_keyring_secret(_service: &str, _account: &str) -> AuthResult<()> {
1332        Err(AuthError::KeyringError(
1333            "Keyring support not compiled".to_string(),
1334        ))
1335    }
1336}
1337
1338// ============================================================================
1339// Singleton
1340// ============================================================================
1341
1342/// Get a shared singleton `Arc<AuthStorage>` instance.
1343///
1344/// Avoids creating multiple `AuthStorage::new()` instances that each
1345/// independently read and cache `auth.json`. All callers share the same
1346/// in-memory state through the `Arc`.
1347pub fn shared_auth_storage() -> Arc<AuthStorage> {
1348    static STORAGE: OnceLock<Arc<AuthStorage>> = OnceLock::new();
1349    STORAGE
1350        .get_or_init(|| {
1351            let storage = Arc::new(AuthStorage::new());
1352            // Default fallback: resolve API keys from environment variables
1353            // using the BuiltinProvider registry (env_key + extra_env_keys).
1354            storage.set_fallback_resolver(Arc::new(EnvVarFallbackResolver));
1355            storage
1356        })
1357        .clone()
1358}
1359
1360// ============================================================================
1361// Tests
1362// ============================================================================
1363
1364#[cfg(test)]
1365mod tests {
1366    use super::*;
1367
1368    #[test]
1369    fn test_auth_storage_new() {
1370        let storage = AuthStorage::in_memory();
1371        assert!(!storage.has("anthropic"));
1372    }
1373
1374    #[test]
1375    fn test_set_and_get_api_key() {
1376        let storage = AuthStorage::in_memory();
1377        storage.set_api_key("anthropic", "sk-test123".to_string());
1378        assert!(storage.has("anthropic"));
1379        assert_eq!(
1380            storage.get_api_key("anthropic"),
1381            Some("sk-test123".to_string())
1382        );
1383    }
1384
1385    /// Reads prefer the canonical file while it exists.
1386    #[test]
1387    fn file_backend_reads_canonical_first() {
1388        let tmp = tempfile::tempdir().unwrap();
1389        let canonical = tmp.path().join("auth.json");
1390        let legacy = tmp.path().join("legacy-auth.json");
1391        std::fs::write(&canonical, r#"{"canonical":true}"#).unwrap();
1392        std::fs::write(&legacy, r#"{"legacy":true}"#).unwrap();
1393
1394        let backend = FileAuthStorage::with_legacy_fallback(canonical, Some(legacy));
1395        let content = backend.read().unwrap().unwrap();
1396        assert!(content.contains("canonical"));
1397        assert!(!content.contains("legacy"));
1398    }
1399
1400    /// While the canonical file is absent, reads fall back to the read-only
1401    /// legacy file — and writes land in the canonical file.
1402    #[test]
1403    fn file_backend_falls_back_to_legacy_read_and_writes_canonical() {
1404        let tmp = tempfile::tempdir().unwrap();
1405        let canonical = tmp.path().join("auth.json");
1406        let legacy = tmp.path().join("legacy-auth.json");
1407        std::fs::write(&legacy, r#"{"legacy":true}"#).unwrap();
1408
1409        let backend = FileAuthStorage::with_legacy_fallback(canonical.clone(), Some(legacy));
1410        let content = backend.read().unwrap().unwrap();
1411        assert!(content.contains("legacy"));
1412
1413        // Write targets the canonical path, never the legacy file.
1414        backend.write(r#"{"fresh":1}"#).unwrap();
1415        assert_eq!(
1416            std::fs::read_to_string(&canonical).unwrap(),
1417            r#"{"fresh":1}"#
1418        );
1419        assert_eq!(
1420            std::fs::read_to_string(backend.legacy_read_path.as_ref().unwrap()).unwrap(),
1421            r#"{"legacy":true}"#,
1422            "legacy file must remain untouched"
1423        );
1424
1425        // Once the canonical file exists, reads use it again.
1426        let content = backend.read().unwrap().unwrap();
1427        assert!(content.contains("fresh"));
1428    }
1429
1430    /// No canonical file, no legacy file → read returns None.
1431    #[test]
1432    fn file_backend_read_none_when_both_missing() {
1433        let tmp = tempfile::tempdir().unwrap();
1434        let canonical = tmp.path().join("auth.json");
1435        let backend = FileAuthStorage::with_legacy_fallback(canonical, None);
1436        assert_eq!(backend.read().unwrap(), None);
1437    }
1438
1439    #[test]
1440    fn test_runtime_override() {
1441        let storage = AuthStorage::in_memory();
1442        storage.set_api_key("anthropic", "stored-key".to_string());
1443        storage.set_runtime_key("anthropic", "runtime-key".to_string());
1444
1445        // Runtime key should take priority
1446        assert_eq!(
1447            storage.get_api_key("anthropic"),
1448            Some("runtime-key".to_string())
1449        );
1450    }
1451
1452    #[test]
1453    fn test_remove_credential() {
1454        let storage = AuthStorage::in_memory();
1455        storage.set_api_key("anthropic", "sk-test123".to_string());
1456        assert!(storage.has("anthropic"));
1457
1458        storage.remove("anthropic");
1459        assert!(!storage.has("anthropic"));
1460    }
1461
1462    #[test]
1463    fn test_auth_status() {
1464        let storage = AuthStorage::in_memory();
1465        storage.set_api_key("anthropic", "sk-test123".to_string());
1466
1467        let status = storage.get_status("anthropic");
1468        assert!(status.configured);
1469        assert_eq!(status.source, Some("stored".to_string()));
1470        assert_eq!(status.label, Some("api_key".to_string()));
1471    }
1472
1473    #[test]
1474    fn test_auth_status_display() {
1475        let status = AuthStatus {
1476            configured: true,
1477            source: Some("stored".to_string()),
1478            label: Some("api_key".to_string()),
1479        };
1480        let display = format!("{}", status);
1481        assert_eq!(display, "stored (api_key)");
1482
1483        let no_config = AuthStatus {
1484            configured: false,
1485            source: None,
1486            label: None,
1487        };
1488        assert_eq!(format!("{}", no_config), "not configured");
1489    }
1490
1491    #[test]
1492    fn test_list_providers() {
1493        let storage = AuthStorage::in_memory();
1494        storage.set_api_key("anthropic", "key1".to_string());
1495        storage.set_api_key("openai", "key2".to_string());
1496
1497        let providers = storage.list_providers();
1498        assert!(providers.contains(&"anthropic".to_string()));
1499        assert!(providers.contains(&"openai".to_string()));
1500    }
1501
1502    #[test]
1503    fn test_oauth_credential() {
1504        let storage = AuthStorage::in_memory();
1505        storage.set_oauth(
1506            "provider",
1507            "access123".to_string(),
1508            Some("refresh456".to_string()),
1509            u64::MAX,
1510        );
1511
1512        assert!(storage.has("provider"));
1513        assert_eq!(
1514            storage.get_api_key("provider"),
1515            Some("access123".to_string())
1516        );
1517    }
1518
1519    #[test]
1520    fn test_expired_oauth_token() {
1521        let storage = AuthStorage::in_memory();
1522        // Set token that expired in the past
1523        storage.set_oauth("provider", "access123".to_string(), None, 0);
1524
1525        // Token should be treated as expired
1526        let key = storage.get_api_key("provider");
1527        assert!(key.is_none());
1528    }
1529
1530    #[test]
1531    fn test_get_all_credentials() {
1532        let storage = AuthStorage::in_memory();
1533        storage.set_api_key("anthropic", "key1".to_string());
1534        storage.set_api_key("openai", "key2".to_string());
1535
1536        let all = storage.get_all();
1537        assert_eq!(all.len(), 2);
1538    }
1539
1540    #[test]
1541    fn test_clear() {
1542        let storage = AuthStorage::in_memory();
1543        storage.set_api_key("anthropic", "key".to_string());
1544        assert!(storage.has("anthropic"));
1545
1546        storage.clear();
1547        assert!(!storage.has("anthropic"));
1548    }
1549
1550    #[test]
1551    fn test_remove_runtime_key() {
1552        let storage = AuthStorage::in_memory();
1553        storage.set_api_key("anthropic", "stored".to_string());
1554        storage.set_runtime_key("anthropic", "runtime".to_string());
1555
1556        assert_eq!(
1557            storage.get_api_key("anthropic"),
1558            Some("runtime".to_string())
1559        );
1560
1561        storage.remove_runtime_key("anthropic");
1562        assert_eq!(storage.get_api_key("anthropic"), Some("stored".to_string()));
1563    }
1564
1565    #[test]
1566    fn test_auth_credential_is_expired() {
1567        // API key never expires
1568        let api_key_cred = AuthCredential::ApiKey {
1569            key: "test".to_string(),
1570        };
1571        assert!(!api_key_cred.is_expired());
1572
1573        // OAuth token that expires in the future
1574        let future_time = now_secs() + 3600;
1575        let oauth_cred = AuthCredential::OAuth {
1576            access_token: "token".to_string(),
1577            refresh_token: Some("refresh".to_string()),
1578            expires_at: future_time,
1579            scopes: None,
1580            provider_data: None,
1581        };
1582        assert!(!oauth_cred.is_expired());
1583
1584        // OAuth token that expired in the past
1585        let oauth_cred_expired = AuthCredential::OAuth {
1586            access_token: "token".to_string(),
1587            refresh_token: Some("refresh".to_string()),
1588            expires_at: 0,
1589            scopes: None,
1590            provider_data: None,
1591        };
1592        assert!(oauth_cred_expired.is_expired());
1593    }
1594
1595    #[test]
1596    fn test_auth_credential_needs_refresh() {
1597        let future_time = now_secs() + 120; // 2 minutes from now
1598
1599        // Has refresh token, will expire soon - not yet within 60s
1600        let oauth_cred = AuthCredential::OAuth {
1601            access_token: "token".to_string(),
1602            refresh_token: Some("refresh".to_string()),
1603            expires_at: future_time,
1604            scopes: None,
1605            provider_data: None,
1606        };
1607        assert!(!oauth_cred.needs_refresh());
1608
1609        // Within 60 seconds
1610        let soon = now_secs() + 30;
1611        let oauth_soon = AuthCredential::OAuth {
1612            access_token: "token".to_string(),
1613            refresh_token: Some("refresh".to_string()),
1614            expires_at: soon,
1615            scopes: None,
1616            provider_data: None,
1617        };
1618        assert!(oauth_soon.needs_refresh());
1619
1620        // No refresh token - doesn't need refresh
1621        let no_refresh = AuthCredential::OAuth {
1622            access_token: "token".to_string(),
1623            refresh_token: None,
1624            expires_at: future_time,
1625            scopes: None,
1626            provider_data: None,
1627        };
1628        assert!(!no_refresh.needs_refresh());
1629
1630        // API key never needs refresh
1631        let api_key_cred = AuthCredential::ApiKey {
1632            key: "test".to_string(),
1633        };
1634        assert!(!api_key_cred.needs_refresh());
1635    }
1636
1637    #[test]
1638    fn test_auth_credential_access_token() {
1639        let future_time = now_secs() + 3600;
1640
1641        let oauth_cred = AuthCredential::OAuth {
1642            access_token: "valid_token".to_string(),
1643            refresh_token: Some("refresh".to_string()),
1644            expires_at: future_time,
1645            scopes: None,
1646            provider_data: None,
1647        };
1648        assert_eq!(oauth_cred.access_token(), Some("valid_token"));
1649
1650        // Expired token
1651        let expired_cred = AuthCredential::OAuth {
1652            access_token: "expired_token".to_string(),
1653            refresh_token: Some("refresh".to_string()),
1654            expires_at: 0,
1655            scopes: None,
1656            provider_data: None,
1657        };
1658        assert!(expired_cred.access_token().is_none());
1659
1660        // API key returns None via access_token
1661        let api_key_cred = AuthCredential::ApiKey {
1662            key: "api_key_token".to_string(),
1663        };
1664        assert!(api_key_cred.access_token().is_none());
1665    }
1666
1667    #[test]
1668    fn test_get_oauth_credential() {
1669        let storage = AuthStorage::in_memory();
1670        storage.set_oauth(
1671            "provider",
1672            "access".to_string(),
1673            Some("refresh".to_string()),
1674            u64::MAX,
1675        );
1676
1677        let cred = storage.get_oauth_credential("provider");
1678        assert!(cred.is_some());
1679        assert!(matches!(cred.unwrap(), AuthCredential::OAuth { .. }));
1680    }
1681
1682    #[test]
1683    fn test_has_oauth_with_refresh() {
1684        let storage = AuthStorage::in_memory();
1685
1686        // With refresh token
1687        storage.set_oauth(
1688            "with_refresh",
1689            "access".to_string(),
1690            Some("refresh".to_string()),
1691            u64::MAX,
1692        );
1693        assert!(storage.has_oauth_with_refresh("with_refresh"));
1694
1695        // Without refresh token
1696        storage.set_oauth("without_refresh", "access".to_string(), None, u64::MAX);
1697        assert!(!storage.has_oauth_with_refresh("without_refresh"));
1698
1699        // API key provider
1700        storage.set_api_key("apikey_provider", "key".to_string());
1701        assert!(!storage.has_oauth_with_refresh("apikey_provider"));
1702    }
1703
1704    #[test]
1705    fn test_set_oauth_full() {
1706        let storage = AuthStorage::in_memory();
1707        storage.set_oauth_full(
1708            "provider",
1709            "access_token".to_string(),
1710            Some("refresh_token".to_string()),
1711            3600,
1712            Some("read write".to_string()),
1713            Some(serde_json::json!({"extra": "data"})),
1714        );
1715
1716        let cred = storage.get_oauth_credential("provider");
1717        assert!(cred.is_some());
1718        if let AuthCredential::OAuth {
1719            scopes,
1720            provider_data,
1721            ..
1722        } = cred.unwrap()
1723        {
1724            assert_eq!(scopes, Some("read write".to_string()));
1725            assert!(provider_data.is_some());
1726        } else {
1727            panic!("Expected OAuth credential");
1728        }
1729    }
1730
1731    #[test]
1732    fn test_session_token() {
1733        let storage = AuthStorage::in_memory();
1734        storage.set_session(
1735            "browser",
1736            "session-token-123".to_string(),
1737            0, // never expires
1738            Some(serde_json::json!({"user": "test"})),
1739        );
1740
1741        assert!(storage.has("browser"));
1742        assert_eq!(
1743            storage.get_api_key("browser"),
1744            Some("session-token-123".to_string())
1745        );
1746
1747        let cred = storage.get("browser").unwrap();
1748        assert!(matches!(cred, AuthCredential::Session { .. }));
1749        assert!(cred.access_token().is_some());
1750    }
1751
1752    #[test]
1753    fn test_session_token_expired() {
1754        let storage = AuthStorage::in_memory();
1755        storage.set_session("browser", "session-token".to_string(), 1, None);
1756
1757        // Token expired (timestamp 1 is in the past)
1758        assert!(storage.get_api_key("browser").is_none());
1759    }
1760
1761    #[test]
1762    fn test_credential_validation() {
1763        // Valid API key
1764        let valid = AuthCredential::ApiKey {
1765            key: "sk-valid".to_string(),
1766        };
1767        assert!(valid.validate().is_ok());
1768
1769        // Empty API key
1770        let empty = AuthCredential::ApiKey {
1771            key: "".to_string(),
1772        };
1773        assert!(empty.validate().is_err());
1774
1775        // Placeholder
1776        let placeholder = AuthCredential::ApiKey {
1777            key: "your-api-key-here".to_string(),
1778        };
1779        assert!(placeholder.validate().is_err());
1780
1781        // Valid OAuth
1782        let valid_oauth = AuthCredential::OAuth {
1783            access_token: "token".to_string(),
1784            refresh_token: None,
1785            expires_at: now_secs() + 3600,
1786            scopes: None,
1787            provider_data: None,
1788        };
1789        assert!(valid_oauth.validate().is_ok());
1790
1791        // Invalid OAuth (empty token)
1792        let invalid_oauth = AuthCredential::OAuth {
1793            access_token: "".to_string(),
1794            refresh_token: None,
1795            expires_at: 1000,
1796            scopes: None,
1797            provider_data: None,
1798        };
1799        assert!(invalid_oauth.validate().is_err());
1800    }
1801
1802    #[test]
1803    fn test_validate_all() {
1804        let storage = AuthStorage::in_memory();
1805        storage.set_api_key("valid", "sk-good".to_string());
1806        storage.set_api_key("empty", "".to_string());
1807
1808        let errors = storage.validate_all();
1809        assert_eq!(errors.len(), 1);
1810        assert_eq!(errors[0].0, "empty");
1811    }
1812
1813    #[test]
1814    fn test_update_oauth_tokens() {
1815        let storage = AuthStorage::in_memory();
1816        storage.set_oauth(
1817            "provider",
1818            "old-access".to_string(),
1819            Some("old-refresh".to_string()),
1820            now_secs() + 3600,
1821        );
1822
1823        storage
1824            .update_oauth_tokens(
1825                "provider",
1826                "new-access".to_string(),
1827                Some("new-refresh".to_string()),
1828                now_secs() + 7200,
1829            )
1830            .unwrap();
1831
1832        let key = storage.get_api_key("provider");
1833        assert_eq!(key, Some("new-access".to_string()));
1834    }
1835
1836    #[test]
1837    fn test_update_oauth_tokens_wrong_type() {
1838        let storage = AuthStorage::in_memory();
1839        storage.set_api_key("provider", "key".to_string());
1840
1841        let result = storage.update_oauth_tokens(
1842            "provider",
1843            "new-access".to_string(),
1844            None,
1845            now_secs() + 3600,
1846        );
1847        assert!(result.is_err());
1848    }
1849
1850    #[test]
1851    fn test_migrate_provider() {
1852        let storage = AuthStorage::in_memory();
1853        storage.set_api_key("old-provider", "key123".to_string());
1854        storage
1855            .migrate_provider("old-provider", "new-provider")
1856            .unwrap();
1857
1858        assert!(!storage.has("old-provider"));
1859        assert!(storage.has("new-provider"));
1860        assert_eq!(
1861            storage.get_api_key("new-provider"),
1862            Some("key123".to_string())
1863        );
1864    }
1865
1866    #[test]
1867    fn test_migrate_provider_not_found() {
1868        let storage = AuthStorage::in_memory();
1869        let result = storage.migrate_provider("nonexistent", "target");
1870        assert!(result.is_err());
1871    }
1872
1873    #[test]
1874    fn test_error_draining() {
1875        let storage = AuthStorage::in_memory();
1876        let errors = storage.drain_errors();
1877        assert!(errors.is_empty());
1878    }
1879
1880    #[test]
1881    fn test_fallback_resolver() {
1882        let storage = AuthStorage::in_memory();
1883        storage.set_fallback_resolver(Arc::new(FnFallbackResolver::new(Box::new(|provider| {
1884            if provider == "custom" {
1885                Some("custom-key-from-config".to_string())
1886            } else {
1887                None
1888            }
1889        }))));
1890
1891        assert_eq!(
1892            storage.get_api_key("custom"),
1893            Some("custom-key-from-config".to_string())
1894        );
1895        assert!(storage.get_api_key("unknown").is_none());
1896
1897        // Without fallback
1898        storage.clear_fallback_resolver();
1899        assert!(storage.get_api_key("custom").is_none());
1900    }
1901
1902    #[test]
1903    fn test_get_api_key_with_options() {
1904        let storage = AuthStorage::in_memory();
1905        storage.set_fallback_resolver(Arc::new(FnFallbackResolver::new(Box::new(|_| {
1906            Some("fallback-key".to_string())
1907        }))));
1908
1909        // With fallback
1910        assert_eq!(
1911            storage.get_api_key_with_options("test", true),
1912            Some("fallback-key".to_string())
1913        );
1914
1915        // Without fallback
1916        assert!(storage.get_api_key_with_options("test", false).is_none());
1917    }
1918
1919    #[test]
1920    fn test_configured_providers() {
1921        let storage = AuthStorage::in_memory();
1922        storage.set_api_key("openai", "key".to_string());
1923        storage.set_api_key("anthropic", "key".to_string());
1924
1925        let providers = storage.configured_providers();
1926        assert!(providers.len() >= 2);
1927        // Should be sorted
1928        let mut sorted = providers.clone();
1929        sorted.sort();
1930        assert_eq!(providers, sorted);
1931    }
1932
1933    #[test]
1934    fn test_has_multiple_providers() {
1935        let storage = AuthStorage::in_memory();
1936        assert!(!storage.has_multiple_providers());
1937
1938        storage.set_api_key("openai", "key1".to_string());
1939        assert!(!storage.has_multiple_providers());
1940
1941        storage.set_api_key("anthropic", "key2".to_string());
1942        assert!(storage.has_multiple_providers());
1943    }
1944
1945    #[test]
1946    fn test_set_and_get_credential() {
1947        let storage = AuthStorage::in_memory();
1948        let cred = AuthCredential::Session {
1949            token: "abc".to_string(),
1950            expires_at: 0,
1951            metadata: None,
1952        };
1953        storage.set("custom", cred);
1954        let retrieved = storage.get("custom");
1955        assert!(retrieved.is_some());
1956        assert!(matches!(retrieved.unwrap(), AuthCredential::Session { .. }));
1957    }
1958
1959    #[test]
1960    fn test_credential_type_name() {
1961        assert_eq!(
1962            AuthCredential::ApiKey {
1963                key: "k".to_string()
1964            }
1965            .type_name(),
1966            "api_key"
1967        );
1968        assert_eq!(
1969            AuthCredential::OAuth {
1970                access_token: "t".to_string(),
1971                refresh_token: None,
1972                expires_at: 0,
1973                scopes: None,
1974                provider_data: None,
1975            }
1976            .type_name(),
1977            "oauth"
1978        );
1979        assert_eq!(
1980            AuthCredential::Session {
1981                token: "t".to_string(),
1982                expires_at: 0,
1983                metadata: None,
1984            }
1985            .type_name(),
1986            "session"
1987        );
1988    }
1989
1990    // ── F-1 secret masking (code audit 2026-07-25) ──────────────────────
1991
1992    #[test]
1993    fn debug_impl_masks_api_key() {
1994        let cred = AuthCredential::ApiKey {
1995            key: "sk-super-secret-12345".to_string(),
1996        };
1997        let dbg = format!("{cred:?}");
1998        assert!(
1999            !dbg.contains("sk-super-secret-12345"),
2000            "Debug must not leak the API key, got: {dbg}"
2001        );
2002        assert!(
2003            dbg.contains("<redacted>"),
2004            "Debug must show <redacted> placeholder, got: {dbg}"
2005        );
2006    }
2007
2008    #[test]
2009    fn debug_impl_masks_oauth_tokens() {
2010        let cred = AuthCredential::OAuth {
2011            access_token: "ya29.access-token-value".to_string(),
2012            refresh_token: Some("ya29.refresh-token-value".to_string()),
2013            expires_at: 9999999999,
2014            scopes: None,
2015            provider_data: None,
2016        };
2017        let dbg = format!("{cred:?}");
2018        assert!(
2019            !dbg.contains("access-token-value"),
2020            "Debug must not leak the access token, got: {dbg}"
2021        );
2022        assert!(
2023            !dbg.contains("refresh-token-value"),
2024            "Debug must not leak the refresh token, got: {dbg}"
2025        );
2026        // Non-secret fields stay visible for debugging expiry issues.
2027        assert!(
2028            dbg.contains("9999999999"),
2029            "Debug must still show expires_at for diagnostics, got: {dbg}"
2030        );
2031    }
2032
2033    #[test]
2034    fn debug_impl_masks_session_token() {
2035        let cred = AuthCredential::Session {
2036            token: "sess-abc-def-ghi".to_string(),
2037            expires_at: 0,
2038            metadata: None,
2039        };
2040        let dbg = format!("{cred:?}");
2041        assert!(
2042            !dbg.contains("sess-abc-def-ghi"),
2043            "Debug must not leak the session token, got: {dbg}"
2044        );
2045        assert!(
2046            dbg.contains("<redacted>"),
2047            "Debug must show <redacted> placeholder, got: {dbg}"
2048        );
2049    }
2050}