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                // Format header value based on mode.
150                // When inject_header is not "Authorization" (e.g., "PRIVATE-TOKEN",
151                // "X-API-Key"), the credential is injected as-is unless the user
152                // explicitly set a custom format. The default "Bearer {}" only
153                // makes sense for the Authorization header.
154                let effective_format = if route.inject_header != "Authorization"
155                    && route.credential_format == "Bearer {}"
156                {
157                    "{}".to_string()
158                } else {
159                    route.credential_format.clone()
160                };
161
162                let header_value = match route.inject_mode {
163                    InjectMode::Header => Zeroizing::new(effective_format.replace("{}", &secret)),
164                    InjectMode::BasicAuth => {
165                        // Base64 encode the credential for Basic auth
166                        let encoded =
167                            base64::engine::general_purpose::STANDARD.encode(secret.as_bytes());
168                        Zeroizing::new(format!("Basic {}", encoded))
169                    }
170                    // For url_path and query_param, header_value is not used
171                    InjectMode::UrlPath | InjectMode::QueryParam => Zeroizing::new(String::new()),
172                };
173
174                credentials.insert(
175                    normalized_prefix.clone(),
176                    LoadedCredential {
177                        inject_mode: route.inject_mode.clone(),
178                        proxy_inject_mode: route
179                            .proxy
180                            .as_ref()
181                            .and_then(|p| p.inject_mode.clone())
182                            .unwrap_or_else(|| route.inject_mode.clone()),
183                        raw_credential: secret,
184                        header_name: route.inject_header.clone(),
185                        proxy_header_name: route
186                            .proxy
187                            .as_ref()
188                            .and_then(|p| p.inject_header.clone())
189                            .unwrap_or_else(|| route.inject_header.clone()),
190                        header_value,
191                        path_pattern: route.path_pattern.clone(),
192                        proxy_path_pattern: route
193                            .proxy
194                            .as_ref()
195                            .and_then(|p| p.path_pattern.clone())
196                            .or_else(|| route.path_pattern.clone()),
197                        path_replacement: route.path_replacement.clone(),
198                        query_param_name: route.query_param_name.clone(),
199                        proxy_query_param_name: route
200                            .proxy
201                            .as_ref()
202                            .and_then(|p| p.query_param_name.clone())
203                            .or_else(|| route.query_param_name.clone()),
204                    },
205                );
206                continue;
207            }
208
209            // OAuth2 client_credentials path
210            if let Some(ref oauth2) = route.oauth2 {
211                debug!(
212                    "Loading OAuth2 credential for route prefix: {}",
213                    route.prefix
214                );
215
216                let client_id =
217                    match nono::keystore::load_secret_by_ref(KEYRING_SERVICE, &oauth2.client_id) {
218                        Ok(s) => s,
219                        Err(nono::NonoError::SecretNotFound(msg))
220                        | Err(nono::NonoError::KeystoreAccess(msg)) => {
221                            warn!(
222                                "OAuth2 client_id not available for route '{}': {}. \
223                                 Managed-credential requests on this route will be denied.",
224                                route.prefix, msg
225                            );
226                            continue;
227                        }
228                        Err(e) => return Err(ProxyError::Credential(e.to_string())),
229                    };
230
231                let client_secret = match nono::keystore::load_secret_by_ref(
232                    KEYRING_SERVICE,
233                    &oauth2.client_secret,
234                ) {
235                    Ok(s) => s,
236                    Err(nono::NonoError::SecretNotFound(msg))
237                    | Err(nono::NonoError::KeystoreAccess(msg)) => {
238                        warn!(
239                            "OAuth2 client_secret not available for route '{}': {}. \
240                             Managed-credential requests on this route will be denied.",
241                            route.prefix, msg
242                        );
243                        continue;
244                    }
245                    Err(e) => return Err(ProxyError::Credential(e.to_string())),
246                };
247
248                let config = OAuth2ExchangeConfig {
249                    token_url: oauth2.token_url.clone(),
250                    client_id,
251                    client_secret,
252                    scope: oauth2.scope.clone(),
253                };
254
255                match TokenCache::new(config, tls_connector.clone()) {
256                    Ok(cache) => {
257                        oauth2_routes.insert(
258                            route.prefix.clone(),
259                            OAuth2Route {
260                                cache,
261                                upstream: route.upstream.clone(),
262                            },
263                        );
264                    }
265                    Err(e) => {
266                        warn!(
267                            "OAuth2 token exchange failed for route '{}': {}. \
268                             Managed-credential requests on this route will be denied.",
269                            route.prefix, e
270                        );
271                        continue;
272                    }
273                }
274            }
275        }
276
277        Ok(Self {
278            credentials,
279            oauth2_routes,
280        })
281    }
282
283    /// Create an empty credential store (no credential injection).
284    #[must_use]
285    pub fn empty() -> Self {
286        Self {
287            credentials: HashMap::new(),
288            oauth2_routes: HashMap::new(),
289        }
290    }
291
292    /// Get a static credential for a route prefix, if configured.
293    #[must_use]
294    pub fn get(&self, prefix: &str) -> Option<&LoadedCredential> {
295        self.credentials.get(prefix)
296    }
297
298    /// Get an OAuth2 route (token cache + upstream) for a route prefix, if configured.
299    #[must_use]
300    pub fn get_oauth2(&self, prefix: &str) -> Option<&OAuth2Route> {
301        self.oauth2_routes.get(prefix)
302    }
303
304    /// Check if any credentials (static or OAuth2) are loaded.
305    #[must_use]
306    pub fn is_empty(&self) -> bool {
307        self.credentials.is_empty() && self.oauth2_routes.is_empty()
308    }
309
310    /// Number of loaded credentials (static + OAuth2).
311    #[must_use]
312    pub fn len(&self) -> usize {
313        self.credentials.len() + self.oauth2_routes.len()
314    }
315
316    /// Returns the set of route prefixes that have loaded credentials
317    /// (both static keystore and OAuth2 routes).
318    #[must_use]
319    pub fn loaded_prefixes(&self) -> std::collections::HashSet<String> {
320        self.credentials
321            .keys()
322            .chain(self.oauth2_routes.keys())
323            .cloned()
324            .collect()
325    }
326}
327
328/// The keyring service name used by nono for all credentials.
329/// Uses the same constant as `nono::keystore::DEFAULT_SERVICE` to ensure consistency.
330const KEYRING_SERVICE: &str = nono::keystore::DEFAULT_SERVICE;
331
332/// Build a hint for the credential-not-found warning that probes other
333/// credential sources for the same name.
334///
335/// Targets the most common confusion pattern in the wild: a route shipped
336/// with `credential_key: env://X` while the user stored their secret in
337/// the system keyring (or vice versa). When we detect the secret in a
338/// *different* source, we name it explicitly so the user can fix the
339/// route's URI in one edit.
340///
341/// The probe is deliberately scoped: we only check the obvious "you put
342/// it in the wrong place" cases (env↔keyring), not URI-managed sources
343/// like `op://` or `apple-password://` whose lookups have side effects.
344fn build_credential_miss_hint(key: &str) -> String {
345    // Case 1: `env://X` failed → the env var isn't set. Check whether a
346    // bare-name keyring entry exists; if so, suggest dropping the prefix.
347    if let Some(var) = key.strip_prefix("env://") {
348        if nono::keystore::load_secret_by_ref(KEYRING_SERVICE, var).is_ok() {
349            return format!(
350                " Tip: a keyring entry exists for '{}'. Change credential_key to bare \
351                 '{}' (no env:// prefix) to use the keyring, or set the env var.",
352                var, var
353            );
354        }
355        return format!(
356            " Looked for env var '{}' (not set). To add to the macOS keychain: \
357             security add-generic-password -s \"nono\" -a \"{}\" -w  — and set credential_key \
358             to bare '{}' (no env:// prefix).",
359            var, var, var
360        );
361    }
362
363    // Case 2: bare key (default keyring) failed → check whether the env
364    // var of the same name is set; if so, suggest the env:// URI.
365    if !key.contains("://") {
366        if std::env::var_os(key).is_some() {
367            return format!(
368                " Tip: env var '{}' is set on the host. Change credential_key to \
369                 'env://{}' to use it, or add a keyring entry for '{}'.",
370                key, key, key
371            );
372        }
373        if cfg!(target_os = "macos") {
374            return format!(
375                " To add it to the macOS keychain: security add-generic-password \
376                 -s \"nono\" -a \"{}\" -w",
377                key
378            );
379        }
380    }
381
382    // URI-managed sources (op://, apple-password://, file://, keyring://)
383    // — no automatic cross-probe; the URI scheme is itself an explicit
384    // statement of where to look, so we trust the user's intent.
385    String::new()
386}
387
388#[cfg(test)]
389#[allow(clippy::unwrap_used)]
390mod tests {
391    use super::*;
392    use std::sync::{Arc, Mutex};
393
394    static ENV_LOCK: Mutex<()> = Mutex::new(());
395
396    struct EnvVarGuard {
397        original: Vec<(&'static str, Option<String>)>,
398    }
399
400    #[allow(clippy::disallowed_methods)]
401    impl EnvVarGuard {
402        fn set_all(vars: &[(&'static str, &str)]) -> Self {
403            let original = vars
404                .iter()
405                .map(|(key, _)| (*key, std::env::var(key).ok()))
406                .collect::<Vec<_>>();
407
408            for (key, value) in vars {
409                // SAFETY: test-only helper; tests using EnvVarGuard are
410                // serialised via #[serial] so no concurrent env mutation.
411                unsafe { std::env::set_var(key, value) };
412            }
413
414            Self { original }
415        }
416    }
417
418    #[allow(clippy::disallowed_methods)]
419    impl Drop for EnvVarGuard {
420        fn drop(&mut self) {
421            for (key, value) in self.original.iter().rev() {
422                // SAFETY: test-only restore; same serialisation guarantee as set_all.
423                match value {
424                    Some(value) => unsafe { std::env::set_var(key, value) },
425                    None => unsafe { std::env::remove_var(key) },
426                }
427            }
428        }
429    }
430
431    /// Build a TLS connector for tests (never used for real connections).
432    fn test_tls_connector() -> TlsConnector {
433        let mut root_store = rustls::RootCertStore::empty();
434        root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
435        let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(
436            rustls::crypto::ring::default_provider(),
437        ))
438        .with_safe_default_protocol_versions()
439        .unwrap()
440        .with_root_certificates(root_store)
441        .with_no_client_auth();
442        TlsConnector::from(Arc::new(tls_config))
443    }
444
445    #[test]
446    fn test_empty_credential_store() {
447        let store = CredentialStore::empty();
448        assert!(store.is_empty());
449        assert_eq!(store.len(), 0);
450        assert!(store.get("openai").is_none());
451        assert!(store.get("/openai").is_none());
452        assert!(store.get_oauth2("/openai").is_none());
453    }
454
455    /// `env://X` lookup misses but the env var IS set on the host (the
456    /// "I think I added the keychain entry but the route is env://"
457    /// case from issue #797): hint should suggest stripping the prefix.
458    /// We simulate this by setting the env var inside the test.
459    #[test]
460    fn test_miss_hint_env_uri_with_keyring_fallback_message() {
461        // We can't actually plant a keyring entry in tests, so this case
462        // exercises the unconditional macOS fallback / cross-platform
463        // suggestion path: the hint should still name the missing var.
464        let hint = build_credential_miss_hint("env://NONONO_TEST_MISSING_VAR");
465        assert!(
466            hint.contains("NONONO_TEST_MISSING_VAR"),
467            "hint should name the missing variable, got: {}",
468            hint
469        );
470    }
471
472    /// Bare key (default keyring lookup) misses but env var IS set —
473    /// hint should suggest the `env://` URI form.
474    #[test]
475    fn test_miss_hint_bare_key_with_env_var_set() {
476        let _lock = ENV_LOCK.lock().expect("env mutex poisoned");
477        let _guard = EnvVarGuard::set_all(&[("NONONO_TEST_BARE_KEY", "secret-value")]);
478
479        let hint = build_credential_miss_hint("NONONO_TEST_BARE_KEY");
480        assert!(
481            hint.contains("env://NONONO_TEST_BARE_KEY"),
482            "hint should suggest env:// URI, got: {}",
483            hint
484        );
485    }
486
487    /// URI-managed sources should not get an automatic cross-probe.
488    #[test]
489    fn test_miss_hint_op_uri_returns_empty() {
490        let hint = build_credential_miss_hint("op://Vault/Item/field");
491        assert!(
492            hint.is_empty(),
493            "URI-managed sources should not get cross-probe hints, got: {}",
494            hint
495        );
496    }
497
498    #[test]
499    fn test_loaded_credential_debug_redacts_secrets() {
500        // Security: Debug output must NEVER contain real secret values.
501        // This prevents accidental leakage in logs, panic messages, or
502        // tracing output at debug level.
503        let cred = LoadedCredential {
504            inject_mode: InjectMode::Header,
505            proxy_inject_mode: InjectMode::Header,
506            raw_credential: Zeroizing::new("sk-secret-12345".to_string()),
507            header_name: "Authorization".to_string(),
508            proxy_header_name: "Authorization".to_string(),
509            header_value: Zeroizing::new("Bearer sk-secret-12345".to_string()),
510            path_pattern: None,
511            proxy_path_pattern: None,
512            path_replacement: None,
513            query_param_name: None,
514            proxy_query_param_name: None,
515        };
516
517        let debug_output = format!("{:?}", cred);
518
519        // Must contain REDACTED markers
520        assert!(
521            debug_output.contains("[REDACTED]"),
522            "Debug output should contain [REDACTED], got: {}",
523            debug_output
524        );
525        // Must NOT contain the actual secret
526        assert!(
527            !debug_output.contains("sk-secret-12345"),
528            "Debug output must not contain the real secret"
529        );
530        assert!(
531            !debug_output.contains("Bearer sk-secret"),
532            "Debug output must not contain the formatted secret"
533        );
534        // Non-secret fields should still be visible
535        assert!(debug_output.contains("Authorization"));
536    }
537
538    #[test]
539    fn test_load_no_credential_routes() {
540        let tls = test_tls_connector();
541        let routes = vec![RouteConfig {
542            prefix: "/test".to_string(),
543            upstream: "https://example.com".to_string(),
544            credential_key: None,
545            inject_mode: InjectMode::Header,
546            inject_header: "Authorization".to_string(),
547            credential_format: "Bearer {}".to_string(),
548            path_pattern: None,
549            path_replacement: None,
550            query_param_name: None,
551            proxy: None,
552            env_var: None,
553            endpoint_rules: vec![],
554            tls_ca: None,
555            tls_client_cert: None,
556            tls_client_key: None,
557            oauth2: None,
558        }];
559        let store = CredentialStore::load(&routes, &tls);
560        assert!(store.is_ok());
561        let store = store.unwrap_or_else(|_| CredentialStore::empty());
562        assert!(store.is_empty());
563    }
564
565    #[test]
566    fn test_get_oauth2_returns_none_for_non_oauth2_routes() {
567        let store = CredentialStore::empty();
568        assert!(store.get_oauth2("openai").is_none());
569        assert!(store.get_oauth2("my-api").is_none());
570    }
571
572    #[test]
573    fn test_is_empty_false_with_only_oauth2_routes() {
574        // Simulate a store with only OAuth2 routes by constructing directly.
575        // We can't call load() with a real OAuth2 config (no token server),
576        // so we build the struct manually to test the is_empty/len logic.
577        use std::time::Duration;
578
579        let cache = make_test_token_cache("test-token", Duration::from_secs(3600));
580        let mut oauth2_routes = HashMap::new();
581        oauth2_routes.insert(
582            "my-api".to_string(),
583            OAuth2Route {
584                cache,
585                upstream: "https://api.example.com".to_string(),
586            },
587        );
588
589        let store = CredentialStore {
590            credentials: HashMap::new(),
591            oauth2_routes,
592        };
593
594        assert!(
595            !store.is_empty(),
596            "store with OAuth2 routes should not be empty"
597        );
598        assert_eq!(store.len(), 1);
599        assert!(store.get_oauth2("my-api").is_some());
600        assert!(store.get("my-api").is_none());
601    }
602
603    #[test]
604    fn test_loaded_prefixes_includes_oauth2() {
605        use std::time::Duration;
606
607        let cache = make_test_token_cache("test-token", Duration::from_secs(3600));
608        let mut oauth2_routes = HashMap::new();
609        oauth2_routes.insert(
610            "my-api".to_string(),
611            OAuth2Route {
612                cache,
613                upstream: "https://api.example.com".to_string(),
614            },
615        );
616
617        let store = CredentialStore {
618            credentials: HashMap::new(),
619            oauth2_routes,
620        };
621
622        let prefixes = store.loaded_prefixes();
623        assert!(prefixes.contains("my-api"));
624    }
625
626    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
627    async fn test_load_oauth2_unreachable_endpoint_skips_route() {
628        use crate::config::OAuth2Config;
629
630        let _lock = ENV_LOCK.lock().unwrap();
631        let _env = EnvVarGuard::set_all(&[
632            ("TEST_OAUTH2_CLIENT_ID", "test-client"),
633            ("TEST_OAUTH2_CLIENT_SECRET", "test-secret"),
634        ]);
635        let tls = test_tls_connector();
636        let routes = vec![RouteConfig {
637            prefix: "my-api".to_string(),
638            upstream: "https://api.example.com".to_string(),
639            credential_key: None,
640            inject_mode: InjectMode::Header,
641            inject_header: "Authorization".to_string(),
642            credential_format: "Bearer {}".to_string(),
643            path_pattern: None,
644            path_replacement: None,
645            query_param_name: None,
646            proxy: None,
647            env_var: Some("MY_API_KEY".to_string()),
648            endpoint_rules: vec![],
649            tls_ca: None,
650            tls_client_cert: None,
651            tls_client_key: None,
652            oauth2: Some(OAuth2Config {
653                // Non-routable address: exchange will fail at TCP connect
654                token_url: "https://127.0.0.1:1/oauth/token".to_string(),
655                // Use env:// refs that point at test env vars
656                client_id: "env://TEST_OAUTH2_CLIENT_ID".to_string(),
657                client_secret: "env://TEST_OAUTH2_CLIENT_SECRET".to_string(),
658                scope: String::new(),
659            }),
660        }];
661
662        let store = CredentialStore::load(&routes, &tls);
663
664        // load() should succeed (route skipped, not hard error)
665        assert!(
666            store.is_ok(),
667            "load should not fail on unreachable OAuth2 endpoint"
668        );
669        let store = store.unwrap();
670
671        // The route should have been skipped (token exchange failed)
672        assert!(
673            store.is_empty(),
674            "unreachable OAuth2 endpoint should result in skipped route"
675        );
676        assert!(store.get_oauth2("my-api").is_none());
677    }
678
679    /// Build a test `TokenCache` with a pre-populated token.
680    fn make_test_token_cache(token: &str, ttl: std::time::Duration) -> TokenCache {
681        use crate::oauth2::OAuth2ExchangeConfig;
682
683        let config = OAuth2ExchangeConfig {
684            token_url: "https://127.0.0.1:1/oauth/token".to_string(),
685            client_id: Zeroizing::new("test-client".to_string()),
686            client_secret: Zeroizing::new("test-secret".to_string()),
687            scope: String::new(),
688        };
689
690        TokenCache::new_from_parts(config, test_tls_connector(), token, ttl)
691    }
692}