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                std::env::set_var(key, value);
410            }
411
412            Self { original }
413        }
414    }
415
416    #[allow(clippy::disallowed_methods)]
417    impl Drop for EnvVarGuard {
418        fn drop(&mut self) {
419            for (key, value) in self.original.iter().rev() {
420                match value {
421                    Some(value) => std::env::set_var(key, value),
422                    None => std::env::remove_var(key),
423                }
424            }
425        }
426    }
427
428    /// Build a TLS connector for tests (never used for real connections).
429    fn test_tls_connector() -> TlsConnector {
430        let mut root_store = rustls::RootCertStore::empty();
431        root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
432        let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(
433            rustls::crypto::ring::default_provider(),
434        ))
435        .with_safe_default_protocol_versions()
436        .unwrap()
437        .with_root_certificates(root_store)
438        .with_no_client_auth();
439        TlsConnector::from(Arc::new(tls_config))
440    }
441
442    #[test]
443    fn test_empty_credential_store() {
444        let store = CredentialStore::empty();
445        assert!(store.is_empty());
446        assert_eq!(store.len(), 0);
447        assert!(store.get("openai").is_none());
448        assert!(store.get("/openai").is_none());
449        assert!(store.get_oauth2("/openai").is_none());
450    }
451
452    /// `env://X` lookup misses but the env var IS set on the host (the
453    /// "I think I added the keychain entry but the route is env://"
454    /// case from issue #797): hint should suggest stripping the prefix.
455    /// We simulate this by setting the env var inside the test.
456    #[test]
457    fn test_miss_hint_env_uri_with_keyring_fallback_message() {
458        // We can't actually plant a keyring entry in tests, so this case
459        // exercises the unconditional macOS fallback / cross-platform
460        // suggestion path: the hint should still name the missing var.
461        let hint = build_credential_miss_hint("env://NONONO_TEST_MISSING_VAR");
462        assert!(
463            hint.contains("NONONO_TEST_MISSING_VAR"),
464            "hint should name the missing variable, got: {}",
465            hint
466        );
467    }
468
469    /// Bare key (default keyring lookup) misses but env var IS set —
470    /// hint should suggest the `env://` URI form.
471    #[test]
472    fn test_miss_hint_bare_key_with_env_var_set() {
473        let _lock = ENV_LOCK.lock().expect("env mutex poisoned");
474        let _guard = EnvVarGuard::set_all(&[("NONONO_TEST_BARE_KEY", "secret-value")]);
475
476        let hint = build_credential_miss_hint("NONONO_TEST_BARE_KEY");
477        assert!(
478            hint.contains("env://NONONO_TEST_BARE_KEY"),
479            "hint should suggest env:// URI, got: {}",
480            hint
481        );
482    }
483
484    /// URI-managed sources should not get an automatic cross-probe.
485    #[test]
486    fn test_miss_hint_op_uri_returns_empty() {
487        let hint = build_credential_miss_hint("op://Vault/Item/field");
488        assert!(
489            hint.is_empty(),
490            "URI-managed sources should not get cross-probe hints, got: {}",
491            hint
492        );
493    }
494
495    #[test]
496    fn test_loaded_credential_debug_redacts_secrets() {
497        // Security: Debug output must NEVER contain real secret values.
498        // This prevents accidental leakage in logs, panic messages, or
499        // tracing output at debug level.
500        let cred = LoadedCredential {
501            inject_mode: InjectMode::Header,
502            proxy_inject_mode: InjectMode::Header,
503            raw_credential: Zeroizing::new("sk-secret-12345".to_string()),
504            header_name: "Authorization".to_string(),
505            proxy_header_name: "Authorization".to_string(),
506            header_value: Zeroizing::new("Bearer sk-secret-12345".to_string()),
507            path_pattern: None,
508            proxy_path_pattern: None,
509            path_replacement: None,
510            query_param_name: None,
511            proxy_query_param_name: None,
512        };
513
514        let debug_output = format!("{:?}", cred);
515
516        // Must contain REDACTED markers
517        assert!(
518            debug_output.contains("[REDACTED]"),
519            "Debug output should contain [REDACTED], got: {}",
520            debug_output
521        );
522        // Must NOT contain the actual secret
523        assert!(
524            !debug_output.contains("sk-secret-12345"),
525            "Debug output must not contain the real secret"
526        );
527        assert!(
528            !debug_output.contains("Bearer sk-secret"),
529            "Debug output must not contain the formatted secret"
530        );
531        // Non-secret fields should still be visible
532        assert!(debug_output.contains("Authorization"));
533    }
534
535    #[test]
536    fn test_load_no_credential_routes() {
537        let tls = test_tls_connector();
538        let routes = vec![RouteConfig {
539            prefix: "/test".to_string(),
540            upstream: "https://example.com".to_string(),
541            credential_key: None,
542            inject_mode: InjectMode::Header,
543            inject_header: "Authorization".to_string(),
544            credential_format: "Bearer {}".to_string(),
545            path_pattern: None,
546            path_replacement: None,
547            query_param_name: None,
548            proxy: None,
549            env_var: None,
550            endpoint_rules: vec![],
551            tls_ca: None,
552            tls_client_cert: None,
553            tls_client_key: None,
554            oauth2: None,
555        }];
556        let store = CredentialStore::load(&routes, &tls);
557        assert!(store.is_ok());
558        let store = store.unwrap_or_else(|_| CredentialStore::empty());
559        assert!(store.is_empty());
560    }
561
562    #[test]
563    fn test_get_oauth2_returns_none_for_non_oauth2_routes() {
564        let store = CredentialStore::empty();
565        assert!(store.get_oauth2("openai").is_none());
566        assert!(store.get_oauth2("my-api").is_none());
567    }
568
569    #[test]
570    fn test_is_empty_false_with_only_oauth2_routes() {
571        // Simulate a store with only OAuth2 routes by constructing directly.
572        // We can't call load() with a real OAuth2 config (no token server),
573        // so we build the struct manually to test the is_empty/len logic.
574        use std::time::Duration;
575
576        let cache = make_test_token_cache("test-token", Duration::from_secs(3600));
577        let mut oauth2_routes = HashMap::new();
578        oauth2_routes.insert(
579            "my-api".to_string(),
580            OAuth2Route {
581                cache,
582                upstream: "https://api.example.com".to_string(),
583            },
584        );
585
586        let store = CredentialStore {
587            credentials: HashMap::new(),
588            oauth2_routes,
589        };
590
591        assert!(
592            !store.is_empty(),
593            "store with OAuth2 routes should not be empty"
594        );
595        assert_eq!(store.len(), 1);
596        assert!(store.get_oauth2("my-api").is_some());
597        assert!(store.get("my-api").is_none());
598    }
599
600    #[test]
601    fn test_loaded_prefixes_includes_oauth2() {
602        use std::time::Duration;
603
604        let cache = make_test_token_cache("test-token", Duration::from_secs(3600));
605        let mut oauth2_routes = HashMap::new();
606        oauth2_routes.insert(
607            "my-api".to_string(),
608            OAuth2Route {
609                cache,
610                upstream: "https://api.example.com".to_string(),
611            },
612        );
613
614        let store = CredentialStore {
615            credentials: HashMap::new(),
616            oauth2_routes,
617        };
618
619        let prefixes = store.loaded_prefixes();
620        assert!(prefixes.contains("my-api"));
621    }
622
623    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
624    async fn test_load_oauth2_unreachable_endpoint_skips_route() {
625        use crate::config::OAuth2Config;
626
627        let _lock = ENV_LOCK.lock().unwrap();
628        let _env = EnvVarGuard::set_all(&[
629            ("TEST_OAUTH2_CLIENT_ID", "test-client"),
630            ("TEST_OAUTH2_CLIENT_SECRET", "test-secret"),
631        ]);
632        let tls = test_tls_connector();
633        let routes = vec![RouteConfig {
634            prefix: "my-api".to_string(),
635            upstream: "https://api.example.com".to_string(),
636            credential_key: None,
637            inject_mode: InjectMode::Header,
638            inject_header: "Authorization".to_string(),
639            credential_format: "Bearer {}".to_string(),
640            path_pattern: None,
641            path_replacement: None,
642            query_param_name: None,
643            proxy: None,
644            env_var: Some("MY_API_KEY".to_string()),
645            endpoint_rules: vec![],
646            tls_ca: None,
647            tls_client_cert: None,
648            tls_client_key: None,
649            oauth2: Some(OAuth2Config {
650                // Non-routable address: exchange will fail at TCP connect
651                token_url: "https://127.0.0.1:1/oauth/token".to_string(),
652                // Use env:// refs that point at test env vars
653                client_id: "env://TEST_OAUTH2_CLIENT_ID".to_string(),
654                client_secret: "env://TEST_OAUTH2_CLIENT_SECRET".to_string(),
655                scope: String::new(),
656            }),
657        }];
658
659        let store = CredentialStore::load(&routes, &tls);
660
661        // load() should succeed (route skipped, not hard error)
662        assert!(
663            store.is_ok(),
664            "load should not fail on unreachable OAuth2 endpoint"
665        );
666        let store = store.unwrap();
667
668        // The route should have been skipped (token exchange failed)
669        assert!(
670            store.is_empty(),
671            "unreachable OAuth2 endpoint should result in skipped route"
672        );
673        assert!(store.get_oauth2("my-api").is_none());
674    }
675
676    /// Build a test `TokenCache` with a pre-populated token.
677    fn make_test_token_cache(token: &str, ttl: std::time::Duration) -> TokenCache {
678        use crate::oauth2::OAuth2ExchangeConfig;
679
680        let config = OAuth2ExchangeConfig {
681            token_url: "https://127.0.0.1:1/oauth/token".to_string(),
682            client_id: Zeroizing::new("test-client".to_string()),
683            client_secret: Zeroizing::new("test-secret".to_string()),
684            scope: String::new(),
685        };
686
687        TokenCache::new_from_parts(config, test_tls_connector(), token, ttl)
688    }
689}