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                        let redacted = redact_credential_ref(key);
140                        warn!(
141                            "Credential '{}' not available for route '{}': {}. \
142                             Managed-credential requests on this route will be denied until the credential is available. \
143                             Set NONO_KEYRING_TIMEOUT_SECS=N (default 120) to wait longer for keychain unlock; 0 disables the timeout.",
144                            redacted, normalized_prefix, msg
145                        );
146                        continue;
147                    }
148                    Err(e) => return Err(ProxyError::Credential(e.to_string())),
149                };
150
151                let effective_format = crate::config::resolved_credential_format(
152                    route.inject_header.as_str(),
153                    route.credential_format.as_deref(),
154                );
155
156                let header_value = match route.inject_mode {
157                    InjectMode::Header => Zeroizing::new(effective_format.replace("{}", &secret)),
158                    InjectMode::BasicAuth => {
159                        // Base64 encode the credential for Basic auth
160                        let encoded =
161                            base64::engine::general_purpose::STANDARD.encode(secret.as_bytes());
162                        Zeroizing::new(format!("Basic {}", encoded))
163                    }
164                    // For url_path and query_param, header_value is not used
165                    InjectMode::UrlPath | InjectMode::QueryParam => Zeroizing::new(String::new()),
166                };
167
168                credentials.insert(
169                    normalized_prefix.clone(),
170                    LoadedCredential {
171                        inject_mode: route.inject_mode.clone(),
172                        proxy_inject_mode: route
173                            .proxy
174                            .as_ref()
175                            .and_then(|p| p.inject_mode.clone())
176                            .unwrap_or_else(|| route.inject_mode.clone()),
177                        raw_credential: secret,
178                        header_name: route.inject_header.clone(),
179                        proxy_header_name: route
180                            .proxy
181                            .as_ref()
182                            .and_then(|p| p.inject_header.clone())
183                            .unwrap_or_else(|| route.inject_header.clone()),
184                        header_value,
185                        path_pattern: route.path_pattern.clone(),
186                        proxy_path_pattern: route
187                            .proxy
188                            .as_ref()
189                            .and_then(|p| p.path_pattern.clone())
190                            .or_else(|| route.path_pattern.clone()),
191                        path_replacement: route.path_replacement.clone(),
192                        query_param_name: route.query_param_name.clone(),
193                        proxy_query_param_name: route
194                            .proxy
195                            .as_ref()
196                            .and_then(|p| p.query_param_name.clone())
197                            .or_else(|| route.query_param_name.clone()),
198                    },
199                );
200                continue;
201            }
202
203            // OAuth2 client_credentials path
204            if let Some(ref oauth2) = route.oauth2 {
205                debug!(
206                    "Loading OAuth2 credential for route prefix: {}",
207                    route.prefix
208                );
209
210                let client_id = match nono::keystore::load_secret_by_ref(
211                    KEYRING_SERVICE,
212                    &oauth2.client_id,
213                ) {
214                    Ok(s) => s,
215                    Err(nono::NonoError::SecretNotFound(msg))
216                    | Err(nono::NonoError::KeystoreAccess(msg)) => {
217                        let redacted = redact_credential_ref(&oauth2.client_id);
218                        warn!(
219                            "OAuth2 client_id '{}' not available for route '{}': {}. \
220                                 Managed-credential requests on this route will be denied. \
221                                 Set NONO_KEYRING_TIMEOUT_SECS=N (default 120) to wait longer for keychain unlock; 0 disables the timeout.",
222                            redacted, route.prefix, msg
223                        );
224                        continue;
225                    }
226                    Err(e) => return Err(ProxyError::Credential(e.to_string())),
227                };
228
229                let client_secret = match nono::keystore::load_secret_by_ref(
230                    KEYRING_SERVICE,
231                    &oauth2.client_secret,
232                ) {
233                    Ok(s) => s,
234                    Err(nono::NonoError::SecretNotFound(msg))
235                    | Err(nono::NonoError::KeystoreAccess(msg)) => {
236                        let redacted = redact_credential_ref(&oauth2.client_secret);
237                        warn!(
238                            "OAuth2 client_secret '{}' not available for route '{}': {}. \
239                             Managed-credential requests on this route will be denied. \
240                             Set NONO_KEYRING_TIMEOUT_SECS=N (default 120) to wait longer for keychain unlock; 0 disables the timeout.",
241                            redacted, 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/// Redact a credential reference for safe display in warnings.
333///
334/// Delegates to the appropriate URI-specific redaction helper so that
335/// secrets (account names, file paths, field names) are never echoed raw.
336fn redact_credential_ref(key: &str) -> String {
337    if nono::keystore::is_op_uri(key) {
338        nono::keystore::redact_op_uri(key)
339    } else if nono::keystore::is_apple_password_uri(key) {
340        nono::keystore::redact_apple_password_uri(key)
341    } else if nono::keystore::is_keyring_uri(key) {
342        nono::keystore::redact_keyring_uri(key)
343    } else if nono::keystore::is_bw_uri(key) {
344        nono::keystore::redact_bw_uri(key)
345    } else if nono::keystore::is_file_uri(key) {
346        nono::keystore::redact_file_uri(key)
347    } else {
348        key.to_string()
349    }
350}
351
352/// Build a hint for the credential-not-found warning that probes other
353/// credential sources for the same name.
354///
355/// Targets the most common confusion pattern in the wild: a route shipped
356/// with `credential_key: env://X` while the user stored their secret in
357/// the system keyring (or vice versa). When we detect the secret in a
358/// *different* source, we name it explicitly so the user can fix the
359/// route's URI in one edit.
360///
361/// The probe is deliberately scoped: we only check the obvious "you put
362/// it in the wrong place" cases (env↔keyring), not URI-managed sources
363/// like `op://` or `apple-password://` whose lookups have side effects.
364fn build_credential_miss_hint(key: &str) -> String {
365    // Case 1: `env://X` failed → the env var isn't set. Check whether a
366    // bare-name keyring entry exists; if so, suggest dropping the prefix.
367    if let Some(var) = key.strip_prefix("env://") {
368        if nono::keystore::load_secret_by_ref(KEYRING_SERVICE, var).is_ok() {
369            return format!(
370                " Tip: a keyring entry exists for '{}'. Change credential_key to bare \
371                 '{}' (no env:// prefix) to use the keyring, or set the env var.",
372                var, var
373            );
374        }
375        return format!(
376            " Looked for env var '{}' (not set). To add to the macOS keychain: \
377             security add-generic-password -s \"nono\" -a \"{}\" -w  — and set credential_key \
378             to bare '{}' (no env:// prefix).",
379            var, var, var
380        );
381    }
382
383    // Case 2: bare key (default keyring) failed → check whether the env
384    // var of the same name is set; if so, suggest the env:// URI.
385    if !key.contains("://") {
386        if std::env::var_os(key).is_some() {
387            return format!(
388                " Tip: env var '{}' is set on the host. Change credential_key to \
389                 'env://{}' to use it, or add a keyring entry for '{}'.",
390                key, key, key
391            );
392        }
393        if cfg!(target_os = "macos") {
394            return format!(
395                " To add it to the macOS keychain: security add-generic-password \
396                 -s \"nono\" -a \"{}\" -w",
397                key
398            );
399        }
400    }
401
402    // URI-managed sources (op://, apple-password://, file://, keyring://)
403    // — no automatic cross-probe; the URI scheme is itself an explicit
404    // statement of where to look, so we trust the user's intent.
405    String::new()
406}
407
408#[cfg(test)]
409#[allow(clippy::unwrap_used)]
410mod tests {
411    use super::*;
412    use std::sync::{Arc, Mutex};
413
414    static ENV_LOCK: Mutex<()> = Mutex::new(());
415
416    struct EnvVarGuard {
417        original: Vec<(&'static str, Option<String>)>,
418    }
419
420    #[allow(clippy::disallowed_methods)]
421    impl EnvVarGuard {
422        fn set_all(vars: &[(&'static str, &str)]) -> Self {
423            let original = vars
424                .iter()
425                .map(|(key, _)| (*key, std::env::var(key).ok()))
426                .collect::<Vec<_>>();
427
428            for (key, value) in vars {
429                // SAFETY: test-only helper; tests using EnvVarGuard are
430                // serialised via #[serial] so no concurrent env mutation.
431                unsafe { std::env::set_var(key, value) };
432            }
433
434            Self { original }
435        }
436    }
437
438    #[allow(clippy::disallowed_methods)]
439    impl Drop for EnvVarGuard {
440        fn drop(&mut self) {
441            for (key, value) in self.original.iter().rev() {
442                // SAFETY: test-only restore; same serialisation guarantee as set_all.
443                match value {
444                    Some(value) => unsafe { std::env::set_var(key, value) },
445                    None => unsafe { std::env::remove_var(key) },
446                }
447            }
448        }
449    }
450
451    /// Build a TLS connector for tests (never used for real connections).
452    fn test_tls_connector() -> TlsConnector {
453        let mut root_store = rustls::RootCertStore::empty();
454        root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
455        let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(
456            rustls::crypto::ring::default_provider(),
457        ))
458        .with_safe_default_protocol_versions()
459        .unwrap()
460        .with_root_certificates(root_store)
461        .with_no_client_auth();
462        TlsConnector::from(Arc::new(tls_config))
463    }
464
465    #[test]
466    fn test_empty_credential_store() {
467        let store = CredentialStore::empty();
468        assert!(store.is_empty());
469        assert_eq!(store.len(), 0);
470        assert!(store.get("openai").is_none());
471        assert!(store.get("/openai").is_none());
472        assert!(store.get_oauth2("/openai").is_none());
473    }
474
475    /// `env://X` lookup misses but the env var IS set on the host (the
476    /// "I think I added the keychain entry but the route is env://"
477    /// case from issue #797): hint should suggest stripping the prefix.
478    /// We simulate this by setting the env var inside the test.
479    #[test]
480    fn test_miss_hint_env_uri_with_keyring_fallback_message() {
481        // We can't actually plant a keyring entry in tests, so this case
482        // exercises the unconditional macOS fallback / cross-platform
483        // suggestion path: the hint should still name the missing var.
484        let hint = build_credential_miss_hint("env://NONONO_TEST_MISSING_VAR");
485        assert!(
486            hint.contains("NONONO_TEST_MISSING_VAR"),
487            "hint should name the missing variable, got: {}",
488            hint
489        );
490    }
491
492    /// Bare key (default keyring lookup) misses but env var IS set —
493    /// hint should suggest the `env://` URI form.
494    #[test]
495    fn test_miss_hint_bare_key_with_env_var_set() {
496        let _lock = ENV_LOCK.lock().expect("env mutex poisoned");
497        let _guard = EnvVarGuard::set_all(&[("NONONO_TEST_BARE_KEY", "secret-value")]);
498
499        let hint = build_credential_miss_hint("NONONO_TEST_BARE_KEY");
500        assert!(
501            hint.contains("env://NONONO_TEST_BARE_KEY"),
502            "hint should suggest env:// URI, got: {}",
503            hint
504        );
505    }
506
507    /// URI-managed sources should not get an automatic cross-probe.
508    #[test]
509    fn test_miss_hint_op_uri_returns_empty() {
510        let hint = build_credential_miss_hint("op://Vault/Item/field");
511        assert!(
512            hint.is_empty(),
513            "URI-managed sources should not get cross-probe hints, got: {}",
514            hint
515        );
516    }
517
518    #[test]
519    fn test_loaded_credential_debug_redacts_secrets() {
520        // Security: Debug output must NEVER contain real secret values.
521        // This prevents accidental leakage in logs, panic messages, or
522        // tracing output at debug level.
523        let cred = LoadedCredential {
524            inject_mode: InjectMode::Header,
525            proxy_inject_mode: InjectMode::Header,
526            raw_credential: Zeroizing::new("sk-secret-12345".to_string()),
527            header_name: "Authorization".to_string(),
528            proxy_header_name: "Authorization".to_string(),
529            header_value: Zeroizing::new("Bearer sk-secret-12345".to_string()),
530            path_pattern: None,
531            proxy_path_pattern: None,
532            path_replacement: None,
533            query_param_name: None,
534            proxy_query_param_name: None,
535        };
536
537        let debug_output = format!("{:?}", cred);
538
539        // Must contain REDACTED markers
540        assert!(
541            debug_output.contains("[REDACTED]"),
542            "Debug output should contain [REDACTED], got: {}",
543            debug_output
544        );
545        // Must NOT contain the actual secret
546        assert!(
547            !debug_output.contains("sk-secret-12345"),
548            "Debug output must not contain the real secret"
549        );
550        assert!(
551            !debug_output.contains("Bearer sk-secret"),
552            "Debug output must not contain the formatted secret"
553        );
554        // Non-secret fields should still be visible
555        assert!(debug_output.contains("Authorization"));
556    }
557
558    #[test]
559    fn test_load_no_credential_routes() {
560        let tls = test_tls_connector();
561        let routes = vec![RouteConfig {
562            prefix: "/test".to_string(),
563            upstream: "https://example.com".to_string(),
564            credential_key: None,
565            inject_mode: InjectMode::Header,
566            inject_header: "Authorization".to_string(),
567            credential_format: Some("Bearer {}".to_string()),
568            path_pattern: None,
569            path_replacement: None,
570            query_param_name: None,
571            proxy: None,
572            env_var: None,
573            endpoint_rules: vec![],
574            tls_ca: None,
575            tls_client_cert: None,
576            tls_client_key: None,
577            oauth2: None,
578        }];
579        let store = CredentialStore::load(&routes, &tls);
580        assert!(store.is_ok());
581        let store = store.unwrap_or_else(|_| CredentialStore::empty());
582        assert!(store.is_empty());
583    }
584
585    #[test]
586    fn test_get_oauth2_returns_none_for_non_oauth2_routes() {
587        let store = CredentialStore::empty();
588        assert!(store.get_oauth2("openai").is_none());
589        assert!(store.get_oauth2("my-api").is_none());
590    }
591
592    #[test]
593    fn test_is_empty_false_with_only_oauth2_routes() {
594        // Simulate a store with only OAuth2 routes by constructing directly.
595        // We can't call load() with a real OAuth2 config (no token server),
596        // so we build the struct manually to test the is_empty/len logic.
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        assert!(
615            !store.is_empty(),
616            "store with OAuth2 routes should not be empty"
617        );
618        assert_eq!(store.len(), 1);
619        assert!(store.get_oauth2("my-api").is_some());
620        assert!(store.get("my-api").is_none());
621    }
622
623    #[test]
624    fn test_loaded_prefixes_includes_oauth2() {
625        use std::time::Duration;
626
627        let cache = make_test_token_cache("test-token", Duration::from_secs(3600));
628        let mut oauth2_routes = HashMap::new();
629        oauth2_routes.insert(
630            "my-api".to_string(),
631            OAuth2Route {
632                cache,
633                upstream: "https://api.example.com".to_string(),
634            },
635        );
636
637        let store = CredentialStore {
638            credentials: HashMap::new(),
639            oauth2_routes,
640        };
641
642        let prefixes = store.loaded_prefixes();
643        assert!(prefixes.contains("my-api"));
644    }
645
646    #[test]
647    fn test_load_non_authorization_header_explicit_bearer_format() {
648        let _lock = ENV_LOCK.lock().expect("env mutex poisoned");
649        let _guard = EnvVarGuard::set_all(&[("NONO_PROXY_TEST_LITELLM_TOKEN", "sk-litellm-test")]);
650        let tls = test_tls_connector();
651        let routes = vec![RouteConfig {
652            prefix: "litellm".to_string(),
653            upstream: "https://litellm".to_string(),
654            credential_key: Some("env://NONO_PROXY_TEST_LITELLM_TOKEN".to_string()),
655            inject_mode: InjectMode::Header,
656            inject_header: "x-litellm-api-key".to_string(),
657            credential_format: Some("Bearer {}".to_string()),
658            path_pattern: None,
659            path_replacement: None,
660            query_param_name: None,
661            proxy: None,
662            env_var: None,
663            endpoint_rules: vec![],
664            tls_ca: None,
665            tls_client_cert: None,
666            tls_client_key: None,
667            oauth2: None,
668        }];
669        let store = CredentialStore::load(&routes, &tls).expect("credential load");
670        let cred = store.get("litellm").expect("route should be loaded");
671        assert_eq!(cred.header_name, "x-litellm-api-key");
672        assert_eq!(cred.header_value.as_str(), "Bearer sk-litellm-test");
673    }
674
675    #[test]
676    fn test_load_non_authorization_header_omitted_format_injects_bare_secret() {
677        let _lock = ENV_LOCK.lock().expect("env mutex poisoned");
678        let _guard = EnvVarGuard::set_all(&[("NONO_PROXY_TEST_API_KEY", "secret-key")]);
679        let tls = test_tls_connector();
680        let routes = vec![RouteConfig {
681            prefix: "api".to_string(),
682            upstream: "https://api.example.com".to_string(),
683            credential_key: Some("env://NONO_PROXY_TEST_API_KEY".to_string()),
684            inject_mode: InjectMode::Header,
685            inject_header: "x-api-key".to_string(),
686            credential_format: None,
687            path_pattern: None,
688            path_replacement: None,
689            query_param_name: None,
690            proxy: None,
691            env_var: None,
692            endpoint_rules: vec![],
693            tls_ca: None,
694            tls_client_cert: None,
695            tls_client_key: None,
696            oauth2: None,
697        }];
698        let store = CredentialStore::load(&routes, &tls).expect("credential load");
699        let cred = store.get("api").expect("route should be loaded");
700        assert_eq!(cred.header_value.as_str(), "secret-key");
701    }
702
703    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
704    async fn test_load_oauth2_unreachable_endpoint_skips_route() {
705        use crate::config::OAuth2Config;
706
707        let _lock = ENV_LOCK.lock().unwrap();
708        let _env = EnvVarGuard::set_all(&[
709            ("TEST_OAUTH2_CLIENT_ID", "test-client"),
710            ("TEST_OAUTH2_CLIENT_SECRET", "test-secret"),
711        ]);
712        let tls = test_tls_connector();
713        let routes = vec![RouteConfig {
714            prefix: "my-api".to_string(),
715            upstream: "https://api.example.com".to_string(),
716            credential_key: None,
717            inject_mode: InjectMode::Header,
718            inject_header: "Authorization".to_string(),
719            credential_format: Some("Bearer {}".to_string()),
720            path_pattern: None,
721            path_replacement: None,
722            query_param_name: None,
723            proxy: None,
724            env_var: Some("MY_API_KEY".to_string()),
725            endpoint_rules: vec![],
726            tls_ca: None,
727            tls_client_cert: None,
728            tls_client_key: None,
729            oauth2: Some(OAuth2Config {
730                // Non-routable address: exchange will fail at TCP connect
731                token_url: "https://127.0.0.1:1/oauth/token".to_string(),
732                // Use env:// refs that point at test env vars
733                client_id: "env://TEST_OAUTH2_CLIENT_ID".to_string(),
734                client_secret: "env://TEST_OAUTH2_CLIENT_SECRET".to_string(),
735                scope: String::new(),
736            }),
737        }];
738
739        let store = CredentialStore::load(&routes, &tls);
740
741        // load() should succeed (route skipped, not hard error)
742        assert!(
743            store.is_ok(),
744            "load should not fail on unreachable OAuth2 endpoint"
745        );
746        let store = store.unwrap();
747
748        // The route should have been skipped (token exchange failed)
749        assert!(
750            store.is_empty(),
751            "unreachable OAuth2 endpoint should result in skipped route"
752        );
753        assert!(store.get_oauth2("my-api").is_none());
754    }
755
756    /// Build a test `TokenCache` with a pre-populated token.
757    fn make_test_token_cache(token: &str, ttl: std::time::Duration) -> TokenCache {
758        use crate::oauth2::OAuth2ExchangeConfig;
759
760        let config = OAuth2ExchangeConfig {
761            token_url: "https://127.0.0.1:1/oauth/token".to_string(),
762            client_id: Zeroizing::new("test-client".to_string()),
763            client_secret: Zeroizing::new("test-secret".to_string()),
764            scope: String::new(),
765        };
766
767        TokenCache::new_from_parts(config, test_tls_connector(), token, ttl)
768    }
769}