Skip to main content

nono_proxy/
credential.rs

1//! Credential loading and management for reverse proxy mode.
2//!
3//! Loads API credentials from the system keystore or 1Password at proxy startup.
4//! Credentials are stored in `Zeroizing<String>` and injected into
5//! requests via headers, URL paths, query parameters, or Basic Auth.
6//! The sandboxed agent never sees the real credentials.
7//!
8//! Route-level configuration (upstream URL, L7 endpoint rules, custom TLS CA)
9//! is handled by [`crate::route::RouteStore`], which loads independently of
10//! credentials. This module handles only credential-specific concerns.
11
12use crate::config::{InjectMode, RouteConfig};
13use crate::error::{ProxyError, Result};
14use crate::oauth2::{OAuth2ExchangeConfig, TokenCache};
15use base64::Engine;
16use std::collections::HashMap;
17use tokio_rustls::TlsConnector;
18use tracing::{debug, warn};
19use zeroize::Zeroizing;
20
21/// A loaded credential ready for injection.
22///
23/// Contains only credential-specific fields (injection mode, header name/value,
24/// raw secret). Route-level configuration (upstream URL, L7 endpoint rules,
25/// custom TLS CA) is stored in [`crate::route::LoadedRoute`].
26pub struct LoadedCredential {
27    /// Upstream injection mode
28    pub inject_mode: InjectMode,
29    /// Proxy-side injection mode used for phantom token parsing.
30    pub proxy_inject_mode: InjectMode,
31    /// Raw credential value from keystore (for modes that need it directly)
32    pub raw_credential: Zeroizing<String>,
33
34    // --- Header mode ---
35    /// Header name to inject (e.g., "Authorization")
36    pub header_name: String,
37    /// Header name used for proxy-side phantom token validation.
38    pub proxy_header_name: String,
39    /// Formatted header value (e.g., "Bearer sk-...")
40    pub header_value: Zeroizing<String>,
41
42    // --- URL path mode ---
43    /// Pattern to match in incoming path (with {} placeholder)
44    pub path_pattern: Option<String>,
45    /// Pattern to match in incoming proxy path (with {} placeholder)
46    pub proxy_path_pattern: Option<String>,
47    /// Pattern for outgoing path (with {} placeholder)
48    pub path_replacement: Option<String>,
49
50    // --- Query param mode ---
51    /// Query parameter name
52    pub query_param_name: Option<String>,
53    /// Proxy-side query parameter name for phantom token validation.
54    pub proxy_query_param_name: Option<String>,
55}
56
57/// Custom Debug impl that redacts secret values to prevent accidental leakage
58/// in logs, panic messages, or debug output.
59impl std::fmt::Debug for LoadedCredential {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.debug_struct("LoadedCredential")
62            .field("inject_mode", &self.inject_mode)
63            .field("proxy_inject_mode", &self.proxy_inject_mode)
64            .field("raw_credential", &"[REDACTED]")
65            .field("header_name", &self.header_name)
66            .field("proxy_header_name", &self.proxy_header_name)
67            .field("header_value", &"[REDACTED]")
68            .field("path_pattern", &self.path_pattern)
69            .field("proxy_path_pattern", &self.proxy_path_pattern)
70            .field("path_replacement", &self.path_replacement)
71            .field("query_param_name", &self.query_param_name)
72            .field("proxy_query_param_name", &self.proxy_query_param_name)
73            .finish()
74    }
75}
76
77/// An OAuth2 route entry: token cache + upstream URL.
78#[derive(Debug)]
79pub struct OAuth2Route {
80    /// Token cache for automatic refresh
81    pub cache: TokenCache,
82    /// Upstream URL (e.g., "https://api.example.com")
83    pub upstream: String,
84}
85
86/// Credential store for all configured routes.
87#[derive(Debug)]
88pub struct CredentialStore {
89    /// Map from route prefix to loaded credential
90    credentials: HashMap<String, LoadedCredential>,
91    /// Map from route prefix to OAuth2 route (token cache + upstream)
92    oauth2_routes: HashMap<String, OAuth2Route>,
93}
94
95impl CredentialStore {
96    /// Load credentials for all configured routes from the system keystore.
97    ///
98    /// Routes without a `credential_key` or `oauth2` block are skipped (no
99    /// credential injection). Routes whose credential is not found remain
100    /// configured but unavailable at request time, so managed-credential
101    /// requests fail closed instead of silently accepting agent-supplied
102    /// upstream credentials.
103    ///
104    /// OAuth2 routes perform an initial token exchange at startup. If the
105    /// exchange fails, the route remains configured but unavailable until
106    /// token acquisition succeeds.
107    ///
108    /// The `tls_connector` is required for OAuth2 token exchange HTTPS calls.
109    ///
110    /// Returns an error only for hard failures (config parse errors,
111    /// non-UTF-8 values). Missing or inaccessible credentials are logged
112    /// as warnings and the route is skipped.
113    pub fn load(routes: &[RouteConfig], tls_connector: &TlsConnector) -> Result<Self> {
114        let mut credentials = HashMap::new();
115        let mut oauth2_routes = HashMap::new();
116
117        for route in routes {
118            // Normalize prefix: strip leading/trailing slashes so it matches
119            // the bare service name returned by parse_service_prefix() in
120            // the reverse proxy path (e.g., "/anthropic" -> "anthropic").
121            let normalized_prefix = route.prefix.trim_matches('/').to_string();
122            if let Some(ref key) = route.credential_key {
123                debug!(
124                    "Loading credential for route prefix: {} (mode: {:?})",
125                    normalized_prefix, route.inject_mode
126                );
127
128                let secret = match nono::keystore::load_secret_by_ref(KEYRING_SERVICE, key) {
129                    Ok(s) => s,
130                    Err(nono::NonoError::SecretNotFound(_)) => {
131                        let hint = build_credential_miss_hint(key);
132                        warn!(
133                            "Credential '{}' not found for route '{}' — managed-credential requests on this route will be denied until the credential is available.{}",
134                            key, normalized_prefix, hint
135                        );
136                        continue;
137                    }
138                    Err(nono::NonoError::KeystoreAccess(msg)) => {
139                        warn!(
140                            "Credential '{}' not available for route '{}': {}. \
141                             Managed-credential requests on this route will be denied until the credential is available.",
142                            key, normalized_prefix, msg
143                        );
144                        continue;
145                    }
146                    Err(e) => return Err(ProxyError::Credential(e.to_string())),
147                };
148
149                let effective_format = crate::config::resolved_credential_format(
150                    route.inject_header.as_str(),
151                    route.credential_format.as_deref(),
152                );
153
154                let header_value = match route.inject_mode {
155                    InjectMode::Header => Zeroizing::new(effective_format.replace("{}", &secret)),
156                    InjectMode::BasicAuth => {
157                        // Base64 encode the credential for Basic auth
158                        let encoded =
159                            base64::engine::general_purpose::STANDARD.encode(secret.as_bytes());
160                        Zeroizing::new(format!("Basic {}", encoded))
161                    }
162                    // For url_path and query_param, header_value is not used
163                    InjectMode::UrlPath | InjectMode::QueryParam => Zeroizing::new(String::new()),
164                };
165
166                credentials.insert(
167                    normalized_prefix.clone(),
168                    LoadedCredential {
169                        inject_mode: route.inject_mode.clone(),
170                        proxy_inject_mode: route
171                            .proxy
172                            .as_ref()
173                            .and_then(|p| p.inject_mode.clone())
174                            .unwrap_or_else(|| route.inject_mode.clone()),
175                        raw_credential: secret,
176                        header_name: route.inject_header.clone(),
177                        proxy_header_name: route
178                            .proxy
179                            .as_ref()
180                            .and_then(|p| p.inject_header.clone())
181                            .unwrap_or_else(|| route.inject_header.clone()),
182                        header_value,
183                        path_pattern: route.path_pattern.clone(),
184                        proxy_path_pattern: route
185                            .proxy
186                            .as_ref()
187                            .and_then(|p| p.path_pattern.clone())
188                            .or_else(|| route.path_pattern.clone()),
189                        path_replacement: route.path_replacement.clone(),
190                        query_param_name: route.query_param_name.clone(),
191                        proxy_query_param_name: route
192                            .proxy
193                            .as_ref()
194                            .and_then(|p| p.query_param_name.clone())
195                            .or_else(|| route.query_param_name.clone()),
196                    },
197                );
198                continue;
199            }
200
201            // OAuth2 client_credentials path
202            if let Some(ref oauth2) = route.oauth2 {
203                debug!(
204                    "Loading OAuth2 credential for route prefix: {}",
205                    route.prefix
206                );
207
208                let client_id =
209                    match nono::keystore::load_secret_by_ref(KEYRING_SERVICE, &oauth2.client_id) {
210                        Ok(s) => s,
211                        Err(nono::NonoError::SecretNotFound(msg))
212                        | Err(nono::NonoError::KeystoreAccess(msg)) => {
213                            warn!(
214                                "OAuth2 client_id not available for route '{}': {}. \
215                                 Managed-credential requests on this route will be denied.",
216                                route.prefix, msg
217                            );
218                            continue;
219                        }
220                        Err(e) => return Err(ProxyError::Credential(e.to_string())),
221                    };
222
223                let client_secret = match nono::keystore::load_secret_by_ref(
224                    KEYRING_SERVICE,
225                    &oauth2.client_secret,
226                ) {
227                    Ok(s) => s,
228                    Err(nono::NonoError::SecretNotFound(msg))
229                    | Err(nono::NonoError::KeystoreAccess(msg)) => {
230                        warn!(
231                            "OAuth2 client_secret not available for route '{}': {}. \
232                             Managed-credential requests on this route will be denied.",
233                            route.prefix, msg
234                        );
235                        continue;
236                    }
237                    Err(e) => return Err(ProxyError::Credential(e.to_string())),
238                };
239
240                let config = OAuth2ExchangeConfig {
241                    token_url: oauth2.token_url.clone(),
242                    client_id,
243                    client_secret,
244                    scope: oauth2.scope.clone(),
245                };
246
247                match TokenCache::new(config, tls_connector.clone()) {
248                    Ok(cache) => {
249                        oauth2_routes.insert(
250                            route.prefix.clone(),
251                            OAuth2Route {
252                                cache,
253                                upstream: route.upstream.clone(),
254                            },
255                        );
256                    }
257                    Err(e) => {
258                        warn!(
259                            "OAuth2 token exchange failed for route '{}': {}. \
260                             Managed-credential requests on this route will be denied.",
261                            route.prefix, e
262                        );
263                        continue;
264                    }
265                }
266            }
267        }
268
269        Ok(Self {
270            credentials,
271            oauth2_routes,
272        })
273    }
274
275    /// Create an empty credential store (no credential injection).
276    #[must_use]
277    pub fn empty() -> Self {
278        Self {
279            credentials: HashMap::new(),
280            oauth2_routes: HashMap::new(),
281        }
282    }
283
284    /// Get a static credential for a route prefix, if configured.
285    #[must_use]
286    pub fn get(&self, prefix: &str) -> Option<&LoadedCredential> {
287        self.credentials.get(prefix)
288    }
289
290    /// Get an OAuth2 route (token cache + upstream) for a route prefix, if configured.
291    #[must_use]
292    pub fn get_oauth2(&self, prefix: &str) -> Option<&OAuth2Route> {
293        self.oauth2_routes.get(prefix)
294    }
295
296    /// Check if any credentials (static or OAuth2) are loaded.
297    #[must_use]
298    pub fn is_empty(&self) -> bool {
299        self.credentials.is_empty() && self.oauth2_routes.is_empty()
300    }
301
302    /// Number of loaded credentials (static + OAuth2).
303    #[must_use]
304    pub fn len(&self) -> usize {
305        self.credentials.len() + self.oauth2_routes.len()
306    }
307
308    /// Returns the set of route prefixes that have loaded credentials
309    /// (both static keystore and OAuth2 routes).
310    #[must_use]
311    pub fn loaded_prefixes(&self) -> std::collections::HashSet<String> {
312        self.credentials
313            .keys()
314            .chain(self.oauth2_routes.keys())
315            .cloned()
316            .collect()
317    }
318}
319
320/// The keyring service name used by nono for all credentials.
321/// Uses the same constant as `nono::keystore::DEFAULT_SERVICE` to ensure consistency.
322const KEYRING_SERVICE: &str = nono::keystore::DEFAULT_SERVICE;
323
324/// Build a hint for the credential-not-found warning that probes other
325/// credential sources for the same name.
326///
327/// Targets the most common confusion pattern in the wild: a route shipped
328/// with `credential_key: env://X` while the user stored their secret in
329/// the system keyring (or vice versa). When we detect the secret in a
330/// *different* source, we name it explicitly so the user can fix the
331/// route's URI in one edit.
332///
333/// The probe is deliberately scoped: we only check the obvious "you put
334/// it in the wrong place" cases (env↔keyring), not URI-managed sources
335/// like `op://` or `apple-password://` whose lookups have side effects.
336fn build_credential_miss_hint(key: &str) -> String {
337    // Case 1: `env://X` failed → the env var isn't set. Check whether a
338    // bare-name keyring entry exists; if so, suggest dropping the prefix.
339    if let Some(var) = key.strip_prefix("env://") {
340        if nono::keystore::load_secret_by_ref(KEYRING_SERVICE, var).is_ok() {
341            return format!(
342                " Tip: a keyring entry exists for '{}'. Change credential_key to bare \
343                 '{}' (no env:// prefix) to use the keyring, or set the env var.",
344                var, var
345            );
346        }
347        return format!(
348            " Looked for env var '{}' (not set). To add to the macOS keychain: \
349             security add-generic-password -s \"nono\" -a \"{}\" -w  — and set credential_key \
350             to bare '{}' (no env:// prefix).",
351            var, var, var
352        );
353    }
354
355    // Case 2: bare key (default keyring) failed → check whether the env
356    // var of the same name is set; if so, suggest the env:// URI.
357    if !key.contains("://") {
358        if std::env::var_os(key).is_some() {
359            return format!(
360                " Tip: env var '{}' is set on the host. Change credential_key to \
361                 'env://{}' to use it, or add a keyring entry for '{}'.",
362                key, key, key
363            );
364        }
365        if cfg!(target_os = "macos") {
366            return format!(
367                " To add it to the macOS keychain: security add-generic-password \
368                 -s \"nono\" -a \"{}\" -w",
369                key
370            );
371        }
372    }
373
374    // URI-managed sources (op://, apple-password://, file://, keyring://)
375    // — no automatic cross-probe; the URI scheme is itself an explicit
376    // statement of where to look, so we trust the user's intent.
377    String::new()
378}
379
380#[cfg(test)]
381#[allow(clippy::unwrap_used)]
382mod tests {
383    use super::*;
384    use std::sync::{Arc, Mutex};
385
386    static ENV_LOCK: Mutex<()> = Mutex::new(());
387
388    struct EnvVarGuard {
389        original: Vec<(&'static str, Option<String>)>,
390    }
391
392    #[allow(clippy::disallowed_methods)]
393    impl EnvVarGuard {
394        fn set_all(vars: &[(&'static str, &str)]) -> Self {
395            let original = vars
396                .iter()
397                .map(|(key, _)| (*key, std::env::var(key).ok()))
398                .collect::<Vec<_>>();
399
400            for (key, value) in vars {
401                // SAFETY: test-only helper; tests using EnvVarGuard are
402                // serialised via #[serial] so no concurrent env mutation.
403                unsafe { std::env::set_var(key, value) };
404            }
405
406            Self { original }
407        }
408    }
409
410    #[allow(clippy::disallowed_methods)]
411    impl Drop for EnvVarGuard {
412        fn drop(&mut self) {
413            for (key, value) in self.original.iter().rev() {
414                // SAFETY: test-only restore; same serialisation guarantee as set_all.
415                match value {
416                    Some(value) => unsafe { std::env::set_var(key, value) },
417                    None => unsafe { std::env::remove_var(key) },
418                }
419            }
420        }
421    }
422
423    /// Build a TLS connector for tests (never used for real connections).
424    fn test_tls_connector() -> TlsConnector {
425        let mut root_store = rustls::RootCertStore::empty();
426        root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
427        let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(
428            rustls::crypto::ring::default_provider(),
429        ))
430        .with_safe_default_protocol_versions()
431        .unwrap()
432        .with_root_certificates(root_store)
433        .with_no_client_auth();
434        TlsConnector::from(Arc::new(tls_config))
435    }
436
437    #[test]
438    fn test_empty_credential_store() {
439        let store = CredentialStore::empty();
440        assert!(store.is_empty());
441        assert_eq!(store.len(), 0);
442        assert!(store.get("openai").is_none());
443        assert!(store.get("/openai").is_none());
444        assert!(store.get_oauth2("/openai").is_none());
445    }
446
447    /// `env://X` lookup misses but the env var IS set on the host (the
448    /// "I think I added the keychain entry but the route is env://"
449    /// case from issue #797): hint should suggest stripping the prefix.
450    /// We simulate this by setting the env var inside the test.
451    #[test]
452    fn test_miss_hint_env_uri_with_keyring_fallback_message() {
453        // We can't actually plant a keyring entry in tests, so this case
454        // exercises the unconditional macOS fallback / cross-platform
455        // suggestion path: the hint should still name the missing var.
456        let hint = build_credential_miss_hint("env://NONONO_TEST_MISSING_VAR");
457        assert!(
458            hint.contains("NONONO_TEST_MISSING_VAR"),
459            "hint should name the missing variable, got: {}",
460            hint
461        );
462    }
463
464    /// Bare key (default keyring lookup) misses but env var IS set —
465    /// hint should suggest the `env://` URI form.
466    #[test]
467    fn test_miss_hint_bare_key_with_env_var_set() {
468        let _lock = ENV_LOCK.lock().expect("env mutex poisoned");
469        let _guard = EnvVarGuard::set_all(&[("NONONO_TEST_BARE_KEY", "secret-value")]);
470
471        let hint = build_credential_miss_hint("NONONO_TEST_BARE_KEY");
472        assert!(
473            hint.contains("env://NONONO_TEST_BARE_KEY"),
474            "hint should suggest env:// URI, got: {}",
475            hint
476        );
477    }
478
479    /// URI-managed sources should not get an automatic cross-probe.
480    #[test]
481    fn test_miss_hint_op_uri_returns_empty() {
482        let hint = build_credential_miss_hint("op://Vault/Item/field");
483        assert!(
484            hint.is_empty(),
485            "URI-managed sources should not get cross-probe hints, got: {}",
486            hint
487        );
488    }
489
490    #[test]
491    fn test_loaded_credential_debug_redacts_secrets() {
492        // Security: Debug output must NEVER contain real secret values.
493        // This prevents accidental leakage in logs, panic messages, or
494        // tracing output at debug level.
495        let cred = LoadedCredential {
496            inject_mode: InjectMode::Header,
497            proxy_inject_mode: InjectMode::Header,
498            raw_credential: Zeroizing::new("sk-secret-12345".to_string()),
499            header_name: "Authorization".to_string(),
500            proxy_header_name: "Authorization".to_string(),
501            header_value: Zeroizing::new("Bearer sk-secret-12345".to_string()),
502            path_pattern: None,
503            proxy_path_pattern: None,
504            path_replacement: None,
505            query_param_name: None,
506            proxy_query_param_name: None,
507        };
508
509        let debug_output = format!("{:?}", cred);
510
511        // Must contain REDACTED markers
512        assert!(
513            debug_output.contains("[REDACTED]"),
514            "Debug output should contain [REDACTED], got: {}",
515            debug_output
516        );
517        // Must NOT contain the actual secret
518        assert!(
519            !debug_output.contains("sk-secret-12345"),
520            "Debug output must not contain the real secret"
521        );
522        assert!(
523            !debug_output.contains("Bearer sk-secret"),
524            "Debug output must not contain the formatted secret"
525        );
526        // Non-secret fields should still be visible
527        assert!(debug_output.contains("Authorization"));
528    }
529
530    #[test]
531    fn test_load_no_credential_routes() {
532        let tls = test_tls_connector();
533        let routes = vec![RouteConfig {
534            prefix: "/test".to_string(),
535            upstream: "https://example.com".to_string(),
536            credential_key: None,
537            inject_mode: InjectMode::Header,
538            inject_header: "Authorization".to_string(),
539            credential_format: Some("Bearer {}".to_string()),
540            path_pattern: None,
541            path_replacement: None,
542            query_param_name: None,
543            proxy: None,
544            env_var: None,
545            endpoint_rules: vec![],
546            tls_ca: None,
547            tls_client_cert: None,
548            tls_client_key: None,
549            oauth2: None,
550        }];
551        let store = CredentialStore::load(&routes, &tls);
552        assert!(store.is_ok());
553        let store = store.unwrap_or_else(|_| CredentialStore::empty());
554        assert!(store.is_empty());
555    }
556
557    #[test]
558    fn test_get_oauth2_returns_none_for_non_oauth2_routes() {
559        let store = CredentialStore::empty();
560        assert!(store.get_oauth2("openai").is_none());
561        assert!(store.get_oauth2("my-api").is_none());
562    }
563
564    #[test]
565    fn test_is_empty_false_with_only_oauth2_routes() {
566        // Simulate a store with only OAuth2 routes by constructing directly.
567        // We can't call load() with a real OAuth2 config (no token server),
568        // so we build the struct manually to test the is_empty/len logic.
569        use std::time::Duration;
570
571        let cache = make_test_token_cache("test-token", Duration::from_secs(3600));
572        let mut oauth2_routes = HashMap::new();
573        oauth2_routes.insert(
574            "my-api".to_string(),
575            OAuth2Route {
576                cache,
577                upstream: "https://api.example.com".to_string(),
578            },
579        );
580
581        let store = CredentialStore {
582            credentials: HashMap::new(),
583            oauth2_routes,
584        };
585
586        assert!(
587            !store.is_empty(),
588            "store with OAuth2 routes should not be empty"
589        );
590        assert_eq!(store.len(), 1);
591        assert!(store.get_oauth2("my-api").is_some());
592        assert!(store.get("my-api").is_none());
593    }
594
595    #[test]
596    fn test_loaded_prefixes_includes_oauth2() {
597        use std::time::Duration;
598
599        let cache = make_test_token_cache("test-token", Duration::from_secs(3600));
600        let mut oauth2_routes = HashMap::new();
601        oauth2_routes.insert(
602            "my-api".to_string(),
603            OAuth2Route {
604                cache,
605                upstream: "https://api.example.com".to_string(),
606            },
607        );
608
609        let store = CredentialStore {
610            credentials: HashMap::new(),
611            oauth2_routes,
612        };
613
614        let prefixes = store.loaded_prefixes();
615        assert!(prefixes.contains("my-api"));
616    }
617
618    #[test]
619    fn test_load_non_authorization_header_explicit_bearer_format() {
620        let _lock = ENV_LOCK.lock().expect("env mutex poisoned");
621        let _guard = EnvVarGuard::set_all(&[("NONO_PROXY_TEST_LITELLM_TOKEN", "sk-litellm-test")]);
622        let tls = test_tls_connector();
623        let routes = vec![RouteConfig {
624            prefix: "litellm".to_string(),
625            upstream: "https://litellm".to_string(),
626            credential_key: Some("env://NONO_PROXY_TEST_LITELLM_TOKEN".to_string()),
627            inject_mode: InjectMode::Header,
628            inject_header: "x-litellm-api-key".to_string(),
629            credential_format: Some("Bearer {}".to_string()),
630            path_pattern: None,
631            path_replacement: None,
632            query_param_name: None,
633            proxy: None,
634            env_var: None,
635            endpoint_rules: vec![],
636            tls_ca: None,
637            tls_client_cert: None,
638            tls_client_key: None,
639            oauth2: None,
640        }];
641        let store = CredentialStore::load(&routes, &tls).expect("credential load");
642        let cred = store.get("litellm").expect("route should be loaded");
643        assert_eq!(cred.header_name, "x-litellm-api-key");
644        assert_eq!(cred.header_value.as_str(), "Bearer sk-litellm-test");
645    }
646
647    #[test]
648    fn test_load_non_authorization_header_omitted_format_injects_bare_secret() {
649        let _lock = ENV_LOCK.lock().expect("env mutex poisoned");
650        let _guard = EnvVarGuard::set_all(&[("NONO_PROXY_TEST_API_KEY", "secret-key")]);
651        let tls = test_tls_connector();
652        let routes = vec![RouteConfig {
653            prefix: "api".to_string(),
654            upstream: "https://api.example.com".to_string(),
655            credential_key: Some("env://NONO_PROXY_TEST_API_KEY".to_string()),
656            inject_mode: InjectMode::Header,
657            inject_header: "x-api-key".to_string(),
658            credential_format: None,
659            path_pattern: None,
660            path_replacement: None,
661            query_param_name: None,
662            proxy: None,
663            env_var: None,
664            endpoint_rules: vec![],
665            tls_ca: None,
666            tls_client_cert: None,
667            tls_client_key: None,
668            oauth2: None,
669        }];
670        let store = CredentialStore::load(&routes, &tls).expect("credential load");
671        let cred = store.get("api").expect("route should be loaded");
672        assert_eq!(cred.header_value.as_str(), "secret-key");
673    }
674
675    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
676    async fn test_load_oauth2_unreachable_endpoint_skips_route() {
677        use crate::config::OAuth2Config;
678
679        let _lock = ENV_LOCK.lock().unwrap();
680        let _env = EnvVarGuard::set_all(&[
681            ("TEST_OAUTH2_CLIENT_ID", "test-client"),
682            ("TEST_OAUTH2_CLIENT_SECRET", "test-secret"),
683        ]);
684        let tls = test_tls_connector();
685        let routes = vec![RouteConfig {
686            prefix: "my-api".to_string(),
687            upstream: "https://api.example.com".to_string(),
688            credential_key: None,
689            inject_mode: InjectMode::Header,
690            inject_header: "Authorization".to_string(),
691            credential_format: Some("Bearer {}".to_string()),
692            path_pattern: None,
693            path_replacement: None,
694            query_param_name: None,
695            proxy: None,
696            env_var: Some("MY_API_KEY".to_string()),
697            endpoint_rules: vec![],
698            tls_ca: None,
699            tls_client_cert: None,
700            tls_client_key: None,
701            oauth2: Some(OAuth2Config {
702                // Non-routable address: exchange will fail at TCP connect
703                token_url: "https://127.0.0.1:1/oauth/token".to_string(),
704                // Use env:// refs that point at test env vars
705                client_id: "env://TEST_OAUTH2_CLIENT_ID".to_string(),
706                client_secret: "env://TEST_OAUTH2_CLIENT_SECRET".to_string(),
707                scope: String::new(),
708            }),
709        }];
710
711        let store = CredentialStore::load(&routes, &tls);
712
713        // load() should succeed (route skipped, not hard error)
714        assert!(
715            store.is_ok(),
716            "load should not fail on unreachable OAuth2 endpoint"
717        );
718        let store = store.unwrap();
719
720        // The route should have been skipped (token exchange failed)
721        assert!(
722            store.is_empty(),
723            "unreachable OAuth2 endpoint should result in skipped route"
724        );
725        assert!(store.get_oauth2("my-api").is_none());
726    }
727
728    /// Build a test `TokenCache` with a pre-populated token.
729    fn make_test_token_cache(token: &str, ttl: std::time::Duration) -> TokenCache {
730        use crate::oauth2::OAuth2ExchangeConfig;
731
732        let config = OAuth2ExchangeConfig {
733            token_url: "https://127.0.0.1:1/oauth/token".to_string(),
734            client_id: Zeroizing::new("test-client".to_string()),
735            client_secret: Zeroizing::new("test-secret".to_string()),
736            scope: String::new(),
737        };
738
739        TokenCache::new_from_parts(config, test_tls_connector(), token, ttl)
740    }
741}