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