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::diagnostic::{ProxyDiagnostic, ProxyDiagnosticCode};
14use crate::error::{ProxyError, Result};
15use crate::oauth2::{OAuth2ExchangeConfig, TokenCache};
16use base64::Engine;
17use std::collections::HashMap;
18use tokio_rustls::TlsConnector;
19use tracing::{debug, warn};
20use zeroize::Zeroizing;
21
22/// A loaded credential ready for injection.
23///
24/// Contains only credential-specific fields (injection mode, header name/value,
25/// raw secret). Route-level configuration (upstream URL, L7 endpoint rules,
26/// custom TLS CA) is stored in [`crate::route::LoadedRoute`].
27pub struct LoadedCredential {
28    /// Upstream injection mode
29    pub inject_mode: InjectMode,
30    /// Proxy-side injection mode used for phantom token parsing.
31    pub proxy_inject_mode: InjectMode,
32    /// Raw credential value from keystore (for modes that need it directly)
33    pub raw_credential: Zeroizing<String>,
34
35    // --- Header mode ---
36    /// Header name to inject (e.g., "Authorization")
37    pub header_name: String,
38    /// Header name used for proxy-side phantom token validation.
39    pub proxy_header_name: String,
40    /// Formatted header value (e.g., "Bearer sk-...")
41    pub header_value: Zeroizing<String>,
42
43    // --- URL path mode ---
44    /// Pattern to match in incoming path (with {} placeholder)
45    pub path_pattern: Option<String>,
46    /// Pattern to match in incoming proxy path (with {} placeholder)
47    pub proxy_path_pattern: Option<String>,
48    /// Pattern for outgoing path (with {} placeholder)
49    pub path_replacement: Option<String>,
50
51    // --- Query param mode ---
52    /// Query parameter name
53    pub query_param_name: Option<String>,
54    /// Proxy-side query parameter name for phantom token validation.
55    pub proxy_query_param_name: Option<String>,
56}
57
58/// Custom Debug impl that redacts secret values to prevent accidental leakage
59/// in logs, panic messages, or debug output.
60impl std::fmt::Debug for LoadedCredential {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.debug_struct("LoadedCredential")
63            .field("inject_mode", &self.inject_mode)
64            .field("proxy_inject_mode", &self.proxy_inject_mode)
65            .field("raw_credential", &"[REDACTED]")
66            .field("header_name", &self.header_name)
67            .field("proxy_header_name", &self.proxy_header_name)
68            .field("header_value", &"[REDACTED]")
69            .field("path_pattern", &self.path_pattern)
70            .field("proxy_path_pattern", &self.proxy_path_pattern)
71            .field("path_replacement", &self.path_replacement)
72            .field("query_param_name", &self.query_param_name)
73            .field("proxy_query_param_name", &self.proxy_query_param_name)
74            .finish()
75    }
76}
77
78/// An OAuth2 route entry: token cache + upstream URL.
79#[derive(Debug)]
80pub struct OAuth2Route {
81    /// Token cache for automatic refresh
82    pub cache: TokenCache,
83    /// Upstream URL (e.g., "https://api.example.com")
84    pub upstream: String,
85}
86
87/// Result of loading credentials at proxy startup.
88#[derive(Debug)]
89pub struct CredentialLoadOutcome {
90    /// Loaded store; may omit routes whose credentials were unavailable.
91    pub store: CredentialStore,
92    /// Per-route warnings for missing or unavailable credentials.
93    pub diagnostics: Vec<ProxyDiagnostic>,
94}
95
96impl CredentialLoadOutcome {
97    #[must_use]
98    pub fn into_store(self) -> CredentialStore {
99        self.store
100    }
101}
102
103/// Credential store for all configured routes.
104#[derive(Debug)]
105pub struct CredentialStore {
106    /// Map from route prefix to loaded credential
107    credentials: HashMap<String, LoadedCredential>,
108    /// Map from route prefix to OAuth2 route (token cache + upstream)
109    oauth2_routes: HashMap<String, OAuth2Route>,
110    /// Map from route prefix to AWS SigV4 route (placeholder until full
111    /// SigV4 signing is implemented; value is () because no runtime state
112    /// is needed yet).
113    aws_routes: HashMap<String, ()>,
114}
115
116impl CredentialStore {
117    /// Load credentials for all configured routes from the system keystore.
118    ///
119    /// Routes without a `credential_key` or `oauth2` block are skipped (no
120    /// credential injection). Routes whose credential is not found remain
121    /// configured but unavailable at request time, so managed-credential
122    /// requests fail closed instead of silently accepting agent-supplied
123    /// upstream credentials.
124    ///
125    /// OAuth2 routes perform an initial token exchange at startup. If the
126    /// exchange fails, the route remains configured but unavailable until
127    /// token acquisition succeeds.
128    ///
129    /// The `tls_connector` is required for OAuth2 token exchange HTTPS calls.
130    ///
131    /// Returns an error only for hard failures (config parse errors,
132    /// non-UTF-8 values). Missing credentials are logged, recorded in
133    /// `diagnostics`, and the route is skipped.
134    pub fn load_with_diagnostics(
135        routes: &[RouteConfig],
136        tls_connector: &TlsConnector,
137    ) -> Result<CredentialLoadOutcome> {
138        let mut credentials = HashMap::new();
139        let mut oauth2_routes = HashMap::new();
140        let mut aws_routes = HashMap::new();
141        let mut diagnostics = Vec::new();
142
143        for route in routes {
144            // Normalize prefix: strip leading/trailing slashes so it matches
145            // the bare service name returned by parse_service_prefix() in
146            // the reverse proxy path (e.g., "/anthropic" -> "anthropic").
147            let normalized_prefix = route.prefix.trim_matches('/').to_string();
148            if let Some(ref key) = route.credential_key {
149                debug!(
150                    "Loading credential for route prefix: {} (mode: {:?})",
151                    normalized_prefix, route.inject_mode
152                );
153
154                let secret = match nono::keystore::load_secret_by_ref(KEYRING_SERVICE, key) {
155                    Ok(s) => s,
156                    Err(nono::NonoError::SecretNotFound(_)) => {
157                        let hint = build_credential_miss_hint(key);
158                        let redacted = redact_credential_ref(key);
159                        let message = format!(
160                            "Credential not found for route '{normalized_prefix}' — \
161                             managed-credential requests on this route will be denied until \
162                             the credential is available.{hint}"
163                        );
164                        warn!("{message}");
165                        diagnostics.push(
166                            ProxyDiagnostic::warning(
167                                ProxyDiagnosticCode::CredentialNotFound,
168                                &normalized_prefix,
169                                message,
170                            )
171                            .with_credential_ref(redacted)
172                            .with_hint(strip_tip_prefix(&hint)),
173                        );
174                        continue;
175                    }
176                    Err(nono::NonoError::KeystoreAccess(msg)) => {
177                        push_secret_unavailable_diagnostic(
178                            &mut diagnostics,
179                            ProxyDiagnosticCode::CredentialUnavailable,
180                            &normalized_prefix,
181                            key,
182                            &msg,
183                            "Credential",
184                            true,
185                        );
186                        continue;
187                    }
188                    Err(e) => return Err(ProxyError::Credential(e.to_string())),
189                };
190
191                let effective_format = crate::config::resolved_credential_format(
192                    route.inject_header.as_str(),
193                    route.credential_format.as_deref(),
194                );
195
196                let header_value = match route.inject_mode {
197                    InjectMode::Header => Zeroizing::new(effective_format.replace("{}", &secret)),
198                    InjectMode::BasicAuth => {
199                        // Base64 encode the credential for Basic auth
200                        let encoded =
201                            base64::engine::general_purpose::STANDARD.encode(secret.as_bytes());
202                        Zeroizing::new(format!("Basic {}", encoded))
203                    }
204                    // For url_path and query_param, header_value is not used
205                    InjectMode::UrlPath | InjectMode::QueryParam => Zeroizing::new(String::new()),
206                };
207
208                credentials.insert(
209                    normalized_prefix.clone(),
210                    LoadedCredential {
211                        inject_mode: route.inject_mode.clone(),
212                        proxy_inject_mode: route
213                            .proxy
214                            .as_ref()
215                            .and_then(|p| p.inject_mode.clone())
216                            .unwrap_or_else(|| route.inject_mode.clone()),
217                        raw_credential: secret,
218                        header_name: route.inject_header.clone(),
219                        proxy_header_name: route
220                            .proxy
221                            .as_ref()
222                            .and_then(|p| p.inject_header.clone())
223                            .unwrap_or_else(|| route.inject_header.clone()),
224                        header_value,
225                        path_pattern: route.path_pattern.clone(),
226                        proxy_path_pattern: route
227                            .proxy
228                            .as_ref()
229                            .and_then(|p| p.path_pattern.clone())
230                            .or_else(|| route.path_pattern.clone()),
231                        path_replacement: route.path_replacement.clone(),
232                        query_param_name: route.query_param_name.clone(),
233                        proxy_query_param_name: route
234                            .proxy
235                            .as_ref()
236                            .and_then(|p| p.query_param_name.clone())
237                            .or_else(|| route.query_param_name.clone()),
238                    },
239                );
240                continue;
241            }
242
243            // OAuth2 client_credentials path
244            if let Some(ref oauth2) = route.oauth2 {
245                debug!(
246                    "Loading OAuth2 credential for route prefix: {}",
247                    route.prefix
248                );
249
250                let Some(client_id) = load_oauth_keystore_ref(
251                    &mut diagnostics,
252                    &route.prefix,
253                    &oauth2.client_id,
254                    "OAuth2 client_id",
255                    ProxyDiagnosticCode::OAuthClientIdUnavailable,
256                )?
257                else {
258                    continue;
259                };
260
261                let Some(client_secret) = load_oauth_keystore_ref(
262                    &mut diagnostics,
263                    &route.prefix,
264                    &oauth2.client_secret,
265                    "OAuth2 client_secret",
266                    ProxyDiagnosticCode::OAuthClientSecretUnavailable,
267                )?
268                else {
269                    continue;
270                };
271
272                let config = OAuth2ExchangeConfig {
273                    token_url: oauth2.token_url.clone(),
274                    client_id,
275                    client_secret,
276                    scope: oauth2.scope.clone(),
277                };
278
279                match TokenCache::new(config, tls_connector.clone()) {
280                    Ok(cache) => {
281                        oauth2_routes.insert(
282                            route.prefix.clone(),
283                            OAuth2Route {
284                                cache,
285                                upstream: route.upstream.clone(),
286                            },
287                        );
288                    }
289                    Err(e) => {
290                        let message = format!(
291                            "OAuth2 token exchange failed for route '{}': {e}. \
292                             Managed-credential requests on this route will be denied.",
293                            route.prefix
294                        );
295                        warn!("{message}");
296                        diagnostics.push(ProxyDiagnostic::warning(
297                            ProxyDiagnosticCode::OAuthTokenExchangeFailed,
298                            &route.prefix,
299                            message,
300                        ));
301                        continue;
302                    }
303                }
304            } else if route.aws_auth.is_some() {
305                // AWS SigV4 path — no credentials to load yet. Register the
306                // prefix so get_aws() returns true and the proxy can return
307                // 501 Not Implemented. The () value is a placeholder; the
308                // real AwsRoute struct will replace it when SigV4 signing is
309                // implemented.
310                aws_routes.insert(normalized_prefix.clone(), ());
311            }
312        }
313
314        Ok(CredentialLoadOutcome {
315            store: Self {
316                credentials,
317                oauth2_routes,
318                aws_routes,
319            },
320            diagnostics,
321        })
322    }
323
324    /// Deprecated wrapper around [`Self::load_with_diagnostics`].
325    #[deprecated(
326        since = "0.64.0",
327        note = "Use `load_with_diagnostics` instead. Will be removed in 1.0.0."
328    )]
329    pub fn load(routes: &[RouteConfig], tls_connector: &TlsConnector) -> Result<CredentialStore> {
330        Self::load_with_diagnostics(routes, tls_connector).map(|outcome| outcome.store)
331    }
332
333    /// Create an empty credential store (no credential injection).
334    #[must_use]
335    pub fn empty() -> Self {
336        Self {
337            credentials: HashMap::new(),
338            oauth2_routes: HashMap::new(),
339            aws_routes: HashMap::new(),
340        }
341    }
342
343    /// Get a static credential for a route prefix, if configured.
344    #[must_use]
345    pub fn get(&self, prefix: &str) -> Option<&LoadedCredential> {
346        self.credentials.get(prefix)
347    }
348
349    /// Get an OAuth2 route (token cache + upstream) for a route prefix, if configured.
350    #[must_use]
351    pub fn get_oauth2(&self, prefix: &str) -> Option<&OAuth2Route> {
352        self.oauth2_routes.get(prefix)
353    }
354
355    /// Returns `Some(())` if an AWS SigV4 route is configured for the given
356    /// prefix, `None` otherwise. The `Option<&()>` return mirrors `get_oauth2`
357    /// so call sites can use `.is_some()` uniformly. The value will become
358    /// `Option<&AwsRoute>` when SigV4 signing is implemented.
359    #[must_use]
360    pub fn get_aws(&self, prefix: &str) -> Option<&()> {
361        self.aws_routes.get(prefix)
362    }
363
364    /// Check if any credentials (static, OAuth2, or AWS) are loaded.
365    #[must_use]
366    pub fn is_empty(&self) -> bool {
367        self.credentials.is_empty() && self.oauth2_routes.is_empty() && self.aws_routes.is_empty()
368    }
369
370    /// Number of loaded credentials (static + OAuth2 + AWS).
371    #[must_use]
372    pub fn len(&self) -> usize {
373        self.credentials.len() + self.oauth2_routes.len() + self.aws_routes.len()
374    }
375
376    /// Returns the set of route prefixes that have loaded credentials
377    /// (static keystore, OAuth2, and AWS routes).
378    #[must_use]
379    pub fn loaded_prefixes(&self) -> std::collections::HashSet<String> {
380        self.credentials
381            .keys()
382            .chain(self.oauth2_routes.keys())
383            .chain(self.aws_routes.keys())
384            .cloned()
385            .collect()
386    }
387}
388
389/// The keyring service name used by nono for all credentials.
390/// Uses the same constant as `nono::keystore::DEFAULT_SERVICE` to ensure consistency.
391const KEYRING_SERVICE: &str = nono::keystore::DEFAULT_SERVICE;
392
393const KEYRING_TIMEOUT_HINT: &str = " Set NONO_KEYRING_TIMEOUT_SECS=N (default 120) to wait longer for keychain unlock; 0 disables the timeout.";
394
395fn redact_credential_ref(key: &str) -> String {
396    if nono::keystore::is_op_uri(key) {
397        nono::keystore::redact_op_uri(key)
398    } else if nono::keystore::is_apple_password_uri(key) {
399        nono::keystore::redact_apple_password_uri(key)
400    } else if nono::keystore::is_keyring_uri(key) {
401        nono::keystore::redact_keyring_uri(key)
402    } else if nono::keystore::is_bw_uri(key) {
403        nono::keystore::redact_bw_uri(key)
404    } else if nono::keystore::is_file_uri(key) {
405        nono::keystore::redact_file_uri(key)
406    } else {
407        key.to_string()
408    }
409}
410
411/// Redact a credential ref and any verbatim repeat of it in a keystore error.
412fn keystore_error_detail(key: &str, msg: &str) -> (String, String) {
413    let redacted = redact_credential_ref(key);
414    let mut detail = msg.replace(key, &redacted);
415    // file:// errors quote the absolute path, not the full URI.
416    if nono::keystore::is_file_uri(key)
417        && let Some(path) = key.strip_prefix("file://")
418        && let Some(redacted_path) = redacted.strip_prefix("file://")
419    {
420        detail = detail.replace(path, redacted_path);
421    }
422    (redacted, detail)
423}
424
425fn push_secret_unavailable_diagnostic(
426    diagnostics: &mut Vec<ProxyDiagnostic>,
427    code: ProxyDiagnosticCode,
428    route_prefix: &str,
429    key: &str,
430    msg: &str,
431    subject: &str,
432    keyring_hint: bool,
433) {
434    let (redacted, detail) = keystore_error_detail(key, msg);
435    let timeout = if keyring_hint {
436        KEYRING_TIMEOUT_HINT
437    } else {
438        ""
439    };
440    let denied = " Managed-credential requests on this route will be denied.";
441    let message =
442        format!("{subject} not available for route '{route_prefix}': {detail}.{denied}{timeout}");
443    warn!(
444        "{subject} '{redacted}' not available for route '{route_prefix}': {detail}.{denied}{timeout}"
445    );
446    diagnostics
447        .push(ProxyDiagnostic::warning(code, route_prefix, message).with_credential_ref(redacted));
448}
449
450fn load_oauth_keystore_ref(
451    diagnostics: &mut Vec<ProxyDiagnostic>,
452    route_prefix: &str,
453    key: &str,
454    subject: &str,
455    code: ProxyDiagnosticCode,
456) -> Result<Option<Zeroizing<String>>> {
457    match nono::keystore::load_secret_by_ref(KEYRING_SERVICE, key) {
458        Ok(s) => Ok(Some(s)),
459        Err(nono::NonoError::SecretNotFound(msg)) => {
460            push_secret_unavailable_diagnostic(
461                diagnostics,
462                code,
463                route_prefix,
464                key,
465                &msg,
466                subject,
467                false,
468            );
469            Ok(None)
470        }
471        Err(nono::NonoError::KeystoreAccess(msg)) => {
472            push_secret_unavailable_diagnostic(
473                diagnostics,
474                code,
475                route_prefix,
476                key,
477                &msg,
478                subject,
479                true,
480            );
481            Ok(None)
482        }
483        Err(e) => Err(ProxyError::Credential(e.to_string())),
484    }
485}
486
487/// Remove the leading "Tip:" prefix from credential miss hints.
488fn strip_tip_prefix(hint: &str) -> String {
489    hint.trim()
490        .strip_prefix("Tip:")
491        .map(str::trim)
492        .unwrap_or(hint.trim())
493        .to_string()
494}
495
496/// Build a hint for the credential-not-found warning that probes other
497/// credential sources for the same name.
498///
499/// Targets the most common confusion pattern in the wild: a route shipped
500/// with `credential_key: env://X` while the user stored their secret in
501/// the system keyring (or vice versa). When we detect the secret in a
502/// *different* source, we name it explicitly so the user can fix the
503/// route's URI in one edit.
504///
505/// The probe is deliberately scoped: we only check the obvious "you put
506/// it in the wrong place" cases (env↔keyring), not URI-managed sources
507/// like `op://` or `apple-password://` whose lookups have side effects.
508fn build_credential_miss_hint(key: &str) -> String {
509    // Case 1: `env://X` failed → the env var isn't set. Check whether a
510    // bare-name keyring entry exists; if so, suggest dropping the prefix.
511    if let Some(var) = key.strip_prefix("env://") {
512        if nono::keystore::load_secret_by_ref(KEYRING_SERVICE, var).is_ok() {
513            return format!(
514                " Tip: a keyring entry exists for '{}'. Change credential_key to bare \
515                 '{}' (no env:// prefix) to use the keyring, or set the env var.",
516                var, var
517            );
518        }
519        return format!(
520            " Looked for env var '{}' (not set). To add to the macOS keychain: \
521             security add-generic-password -s \"nono\" -a \"{}\" -w  — and set credential_key \
522             to bare '{}' (no env:// prefix).",
523            var, var, var
524        );
525    }
526
527    // Case 2: bare key (default keyring) failed → check whether the env
528    // var of the same name is set; if so, suggest the env:// URI.
529    if !key.contains("://") {
530        if std::env::var_os(key).is_some() {
531            return format!(
532                " Tip: env var '{}' is set on the host. Change credential_key to \
533                 'env://{}' to use it, or add a keyring entry for '{}'.",
534                key, key, key
535            );
536        }
537        if cfg!(target_os = "macos") {
538            return format!(
539                " To add it to the macOS keychain: security add-generic-password \
540                 -s \"nono\" -a \"{}\" -w",
541                key
542            );
543        }
544    }
545
546    // URI-managed sources (op://, apple-password://, file://, keyring://)
547    // — no automatic cross-probe; the URI scheme is itself an explicit
548    // statement of where to look, so we trust the user's intent.
549    String::new()
550}
551
552#[cfg(test)]
553#[allow(clippy::unwrap_used)]
554mod tests {
555    use super::*;
556    use std::sync::{Arc, Mutex};
557
558    static ENV_LOCK: Mutex<()> = Mutex::new(());
559
560    struct EnvVarGuard {
561        original: Vec<(&'static str, Option<String>)>,
562    }
563
564    #[allow(clippy::disallowed_methods)]
565    impl EnvVarGuard {
566        fn set_all(vars: &[(&'static str, &str)]) -> Self {
567            let original = vars
568                .iter()
569                .map(|(key, _)| (*key, std::env::var(key).ok()))
570                .collect::<Vec<_>>();
571
572            for (key, value) in vars {
573                // SAFETY: test-only helper; tests using EnvVarGuard are
574                // serialised via #[serial] so no concurrent env mutation.
575                unsafe { std::env::set_var(key, value) };
576            }
577
578            Self { original }
579        }
580    }
581
582    #[allow(clippy::disallowed_methods)]
583    impl Drop for EnvVarGuard {
584        fn drop(&mut self) {
585            for (key, value) in self.original.iter().rev() {
586                // SAFETY: test-only restore; same serialisation guarantee as set_all.
587                match value {
588                    Some(value) => unsafe { std::env::set_var(key, value) },
589                    None => unsafe { std::env::remove_var(key) },
590                }
591            }
592        }
593    }
594
595    /// Build a TLS connector for tests (never used for real connections).
596    fn test_tls_connector() -> TlsConnector {
597        let mut root_store = rustls::RootCertStore::empty();
598        root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
599        let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(
600            rustls::crypto::ring::default_provider(),
601        ))
602        .with_safe_default_protocol_versions()
603        .unwrap()
604        .with_root_certificates(root_store)
605        .with_no_client_auth();
606        TlsConnector::from(Arc::new(tls_config))
607    }
608
609    #[test]
610    fn test_empty_credential_store() {
611        let store = CredentialStore::empty();
612        assert!(store.is_empty());
613        assert_eq!(store.len(), 0);
614        assert!(store.get("openai").is_none());
615        assert!(store.get("/openai").is_none());
616        assert!(store.get_oauth2("/openai").is_none());
617    }
618
619    /// `env://X` lookup misses but the env var IS set on the host (the
620    /// "I think I added the keychain entry but the route is env://"
621    /// case from issue #797): hint should suggest stripping the prefix.
622    /// We simulate this by setting the env var inside the test.
623    #[test]
624    fn test_miss_hint_env_uri_with_keyring_fallback_message() {
625        // We can't actually plant a keyring entry in tests, so this case
626        // exercises the unconditional macOS fallback / cross-platform
627        // suggestion path: the hint should still name the missing var.
628        let hint = build_credential_miss_hint("env://NONONO_TEST_MISSING_VAR");
629        assert!(
630            hint.contains("NONONO_TEST_MISSING_VAR"),
631            "hint should name the missing variable, got: {}",
632            hint
633        );
634    }
635
636    /// Bare key (default keyring lookup) misses but env var IS set —
637    /// hint should suggest the `env://` URI form.
638    #[test]
639    fn test_miss_hint_bare_key_with_env_var_set() {
640        let _lock = ENV_LOCK.lock().expect("env mutex poisoned");
641        let _guard = EnvVarGuard::set_all(&[("NONONO_TEST_BARE_KEY", "secret-value")]);
642
643        let hint = build_credential_miss_hint("NONONO_TEST_BARE_KEY");
644        assert!(
645            hint.contains("env://NONONO_TEST_BARE_KEY"),
646            "hint should suggest env:// URI, got: {}",
647            hint
648        );
649    }
650
651    /// URI-managed sources should not get an automatic cross-probe.
652    #[test]
653    fn test_miss_hint_op_uri_returns_empty() {
654        let hint = build_credential_miss_hint("op://Vault/Item/field");
655        assert!(
656            hint.is_empty(),
657            "URI-managed sources should not get cross-probe hints, got: {}",
658            hint
659        );
660    }
661
662    #[test]
663    fn test_loaded_credential_debug_redacts_secrets() {
664        // Security: Debug output must NEVER contain real secret values.
665        // This prevents accidental leakage in logs, panic messages, or
666        // tracing output at debug level.
667        let cred = LoadedCredential {
668            inject_mode: InjectMode::Header,
669            proxy_inject_mode: InjectMode::Header,
670            raw_credential: Zeroizing::new("sk-secret-12345".to_string()),
671            header_name: "Authorization".to_string(),
672            proxy_header_name: "Authorization".to_string(),
673            header_value: Zeroizing::new("Bearer sk-secret-12345".to_string()),
674            path_pattern: None,
675            proxy_path_pattern: None,
676            path_replacement: None,
677            query_param_name: None,
678            proxy_query_param_name: None,
679        };
680
681        let debug_output = format!("{:?}", cred);
682
683        // Must contain REDACTED markers
684        assert!(
685            debug_output.contains("[REDACTED]"),
686            "Debug output should contain [REDACTED], got: {}",
687            debug_output
688        );
689        // Must NOT contain the actual secret
690        assert!(
691            !debug_output.contains("sk-secret-12345"),
692            "Debug output must not contain the real secret"
693        );
694        assert!(
695            !debug_output.contains("Bearer sk-secret"),
696            "Debug output must not contain the formatted secret"
697        );
698        // Non-secret fields should still be visible
699        assert!(debug_output.contains("Authorization"));
700    }
701
702    fn oauth2_route_with_refs(
703        prefix: &str,
704        client_id: &str,
705        client_secret: &str,
706        token_url: &str,
707    ) -> RouteConfig {
708        use crate::config::OAuth2Config;
709
710        RouteConfig {
711            prefix: prefix.to_string(),
712            upstream: "https://api.example.com".to_string(),
713            credential_key: None,
714            inject_mode: InjectMode::Header,
715            inject_header: "Authorization".to_string(),
716            credential_format: Some("Bearer {}".to_string()),
717            path_pattern: None,
718            path_replacement: None,
719            query_param_name: None,
720            proxy: None,
721            env_var: Some("MY_API_KEY".to_string()),
722            endpoint_rules: vec![],
723            tls_ca: None,
724            tls_client_cert: None,
725            tls_client_key: None,
726            oauth2: Some(OAuth2Config {
727                token_url: token_url.to_string(),
728                client_id: client_id.to_string(),
729                client_secret: client_secret.to_string(),
730                scope: String::new(),
731            }),
732            aws_auth: None,
733        }
734    }
735
736    #[test]
737    fn test_load_missing_env_credential_records_credential_not_found() {
738        let tls = test_tls_connector();
739        let routes = vec![RouteConfig {
740            prefix: "preview-missing".to_string(),
741            upstream: "https://api.example.com".to_string(),
742            credential_key: Some("env://NONO_PROXY_TEST_MISSING_CRED".to_string()),
743            inject_mode: InjectMode::Header,
744            inject_header: "Authorization".to_string(),
745            credential_format: Some("Bearer {}".to_string()),
746            path_pattern: None,
747            path_replacement: None,
748            query_param_name: None,
749            proxy: None,
750            env_var: None,
751            endpoint_rules: vec![],
752            tls_ca: None,
753            tls_client_cert: None,
754            tls_client_key: None,
755            oauth2: None,
756            aws_auth: None,
757        }];
758        let outcome = CredentialStore::load_with_diagnostics(&routes, &tls).expect("load");
759        assert!(outcome.store.is_empty());
760        assert_eq!(outcome.diagnostics.len(), 1);
761        assert_eq!(
762            outcome.diagnostics[0].code,
763            ProxyDiagnosticCode::CredentialNotFound
764        );
765        assert_eq!(
766            outcome.diagnostics[0].credential_ref.as_deref(),
767            Some("env://NONO_PROXY_TEST_MISSING_CRED")
768        );
769    }
770
771    #[test]
772    fn test_redact_credential_ref_op_uri() {
773        assert_eq!(
774            redact_credential_ref("op://vault/item/secret"),
775            "op://vault/item/<redacted>"
776        );
777    }
778
779    #[test]
780    fn test_keystore_error_detail_redacts_credential_ref_in_message() {
781        let cases = [
782            (
783                "op://Vault/Item/secret",
784                "1Password lookup failed for 'op://Vault/Item/secret': timed out",
785                "op://Vault/Item/<redacted>",
786                "/secret",
787            ),
788            (
789                "file:///run/secrets/api-token",
790                "failed to read credential file '/run/secrets/api-token'",
791                "/run/secrets/[REDACTED]",
792                "api-token",
793            ),
794        ];
795        for (key, msg, want, leak) in cases {
796            let (_redacted, detail) = keystore_error_detail(key, msg);
797            assert!(
798                detail.contains(want),
799                "expected redacted fragment '{want}' in '{detail}'"
800            );
801            assert!(
802                !detail.contains(leak),
803                "raw credential fragment '{leak}' leaked in '{detail}'"
804            );
805        }
806    }
807
808    #[test]
809    fn test_load_oauth2_missing_client_id_records_diagnostic() {
810        let tls = test_tls_connector();
811        let routes = vec![oauth2_route_with_refs(
812            "my-api",
813            "env://NONO_PROXY_TEST_MISSING_CLIENT_ID",
814            "env://NONO_PROXY_TEST_CLIENT_SECRET",
815            "https://127.0.0.1:1/oauth/token",
816        )];
817        let outcome = CredentialStore::load_with_diagnostics(&routes, &tls).expect("load");
818        assert!(outcome.store.is_empty());
819        assert_eq!(outcome.diagnostics.len(), 1);
820        assert_eq!(
821            outcome.diagnostics[0].code,
822            ProxyDiagnosticCode::OAuthClientIdUnavailable
823        );
824    }
825
826    #[test]
827    fn test_load_oauth2_missing_client_secret_records_diagnostic() {
828        let _lock = ENV_LOCK.lock().expect("env mutex poisoned");
829        let _env = EnvVarGuard::set_all(&[("NONO_PROXY_TEST_CLIENT_ID", "test-client")]);
830        let tls = test_tls_connector();
831        let routes = vec![oauth2_route_with_refs(
832            "my-api",
833            "env://NONO_PROXY_TEST_CLIENT_ID",
834            "env://NONO_PROXY_TEST_MISSING_CLIENT_SECRET",
835            "https://127.0.0.1:1/oauth/token",
836        )];
837        let outcome = CredentialStore::load_with_diagnostics(&routes, &tls).expect("load");
838        assert!(outcome.store.is_empty());
839        assert_eq!(outcome.diagnostics.len(), 1);
840        assert_eq!(
841            outcome.diagnostics[0].code,
842            ProxyDiagnosticCode::OAuthClientSecretUnavailable
843        );
844    }
845
846    #[test]
847    fn test_load_no_credential_routes() {
848        let tls = test_tls_connector();
849        let routes = vec![RouteConfig {
850            prefix: "/test".to_string(),
851            upstream: "https://example.com".to_string(),
852            credential_key: None,
853            inject_mode: InjectMode::Header,
854            inject_header: "Authorization".to_string(),
855            credential_format: Some("Bearer {}".to_string()),
856            path_pattern: None,
857            path_replacement: None,
858            query_param_name: None,
859            proxy: None,
860            env_var: None,
861            endpoint_rules: vec![],
862            tls_ca: None,
863            tls_client_cert: None,
864            tls_client_key: None,
865            oauth2: None,
866            aws_auth: None,
867        }];
868        let outcome = CredentialStore::load_with_diagnostics(&routes, &tls);
869        assert!(outcome.is_ok());
870        let store = outcome
871            .unwrap_or_else(|_| CredentialLoadOutcome {
872                store: CredentialStore::empty(),
873                diagnostics: Vec::new(),
874            })
875            .store;
876        assert!(store.is_empty());
877    }
878
879    #[test]
880    fn test_get_oauth2_returns_none_for_non_oauth2_routes() {
881        let store = CredentialStore::empty();
882        assert!(store.get_oauth2("openai").is_none());
883        assert!(store.get_oauth2("my-api").is_none());
884    }
885
886    #[test]
887    fn test_is_empty_false_with_only_oauth2_routes() {
888        // Simulate a store with only OAuth2 routes by constructing directly.
889        // We can't call load() with a real OAuth2 config (no token server),
890        // so we build the struct manually to test the is_empty/len logic.
891        use std::time::Duration;
892
893        let cache = make_test_token_cache("test-token", Duration::from_secs(3600));
894        let mut oauth2_routes = HashMap::new();
895        oauth2_routes.insert(
896            "my-api".to_string(),
897            OAuth2Route {
898                cache,
899                upstream: "https://api.example.com".to_string(),
900            },
901        );
902
903        let store = CredentialStore {
904            credentials: HashMap::new(),
905            oauth2_routes,
906            aws_routes: HashMap::new(),
907        };
908
909        assert!(
910            !store.is_empty(),
911            "store with OAuth2 routes should not be empty"
912        );
913        assert_eq!(store.len(), 1);
914        assert!(store.get_oauth2("my-api").is_some());
915        assert!(store.get("my-api").is_none());
916    }
917
918    #[test]
919    fn test_loaded_prefixes_includes_oauth2() {
920        use std::time::Duration;
921
922        let cache = make_test_token_cache("test-token", Duration::from_secs(3600));
923        let mut oauth2_routes = HashMap::new();
924        oauth2_routes.insert(
925            "my-api".to_string(),
926            OAuth2Route {
927                cache,
928                upstream: "https://api.example.com".to_string(),
929            },
930        );
931
932        let store = CredentialStore {
933            credentials: HashMap::new(),
934            oauth2_routes,
935            aws_routes: HashMap::new(),
936        };
937
938        let prefixes = store.loaded_prefixes();
939        assert!(prefixes.contains("my-api"));
940    }
941
942    #[test]
943    fn test_load_non_authorization_header_explicit_bearer_format() {
944        let _lock = ENV_LOCK.lock().expect("env mutex poisoned");
945        let _guard = EnvVarGuard::set_all(&[("NONO_PROXY_TEST_LITELLM_TOKEN", "sk-litellm-test")]);
946        let tls = test_tls_connector();
947        let routes = vec![RouteConfig {
948            prefix: "litellm".to_string(),
949            upstream: "https://litellm".to_string(),
950            credential_key: Some("env://NONO_PROXY_TEST_LITELLM_TOKEN".to_string()),
951            inject_mode: InjectMode::Header,
952            inject_header: "x-litellm-api-key".to_string(),
953            credential_format: Some("Bearer {}".to_string()),
954            path_pattern: None,
955            path_replacement: None,
956            query_param_name: None,
957            proxy: None,
958            env_var: None,
959            endpoint_rules: vec![],
960            tls_ca: None,
961            tls_client_cert: None,
962            tls_client_key: None,
963            oauth2: None,
964            aws_auth: None,
965        }];
966        let store = CredentialStore::load_with_diagnostics(&routes, &tls)
967            .expect("credential load")
968            .store;
969        let cred = store.get("litellm").expect("route should be loaded");
970        assert_eq!(cred.header_name, "x-litellm-api-key");
971        assert_eq!(cred.header_value.as_str(), "Bearer sk-litellm-test");
972    }
973
974    #[test]
975    fn test_load_non_authorization_header_omitted_format_injects_bare_secret() {
976        let _lock = ENV_LOCK.lock().expect("env mutex poisoned");
977        let _guard = EnvVarGuard::set_all(&[("NONO_PROXY_TEST_API_KEY", "secret-key")]);
978        let tls = test_tls_connector();
979        let routes = vec![RouteConfig {
980            prefix: "api".to_string(),
981            upstream: "https://api.example.com".to_string(),
982            credential_key: Some("env://NONO_PROXY_TEST_API_KEY".to_string()),
983            inject_mode: InjectMode::Header,
984            inject_header: "x-api-key".to_string(),
985            credential_format: None,
986            path_pattern: None,
987            path_replacement: None,
988            query_param_name: None,
989            proxy: None,
990            env_var: None,
991            endpoint_rules: vec![],
992            tls_ca: None,
993            tls_client_cert: None,
994            tls_client_key: None,
995            oauth2: None,
996            aws_auth: None,
997        }];
998        let store = CredentialStore::load_with_diagnostics(&routes, &tls)
999            .expect("credential load")
1000            .store;
1001        let cred = store.get("api").expect("route should be loaded");
1002        assert_eq!(cred.header_value.as_str(), "secret-key");
1003    }
1004
1005    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1006    async fn test_load_oauth2_unreachable_endpoint_skips_route() {
1007        use crate::config::OAuth2Config;
1008
1009        let _lock = ENV_LOCK.lock().unwrap();
1010        let _env = EnvVarGuard::set_all(&[
1011            ("TEST_OAUTH2_CLIENT_ID", "test-client"),
1012            ("TEST_OAUTH2_CLIENT_SECRET", "test-secret"),
1013        ]);
1014        let tls = test_tls_connector();
1015        let routes = vec![RouteConfig {
1016            prefix: "my-api".to_string(),
1017            upstream: "https://api.example.com".to_string(),
1018            credential_key: None,
1019            inject_mode: InjectMode::Header,
1020            inject_header: "Authorization".to_string(),
1021            credential_format: Some("Bearer {}".to_string()),
1022            path_pattern: None,
1023            path_replacement: None,
1024            query_param_name: None,
1025            proxy: None,
1026            env_var: Some("MY_API_KEY".to_string()),
1027            endpoint_rules: vec![],
1028            tls_ca: None,
1029            tls_client_cert: None,
1030            tls_client_key: None,
1031            oauth2: Some(OAuth2Config {
1032                // Non-routable address: exchange will fail at TCP connect
1033                token_url: "https://127.0.0.1:1/oauth/token".to_string(),
1034                // Use env:// refs that point at test env vars
1035                client_id: "env://TEST_OAUTH2_CLIENT_ID".to_string(),
1036                client_secret: "env://TEST_OAUTH2_CLIENT_SECRET".to_string(),
1037                scope: String::new(),
1038            }),
1039            aws_auth: None,
1040        }];
1041
1042        let outcome = CredentialStore::load_with_diagnostics(&routes, &tls);
1043
1044        // load() should succeed (route skipped, not hard error)
1045        assert!(
1046            outcome.is_ok(),
1047            "load should not fail on unreachable OAuth2 endpoint"
1048        );
1049        let outcome = outcome.unwrap();
1050        let store = outcome.store;
1051
1052        // The route should have been skipped (token exchange failed)
1053        assert!(
1054            store.is_empty(),
1055            "unreachable OAuth2 endpoint should result in skipped route"
1056        );
1057        assert!(store.get_oauth2("my-api").is_none());
1058        assert_eq!(outcome.diagnostics.len(), 1);
1059        assert_eq!(
1060            outcome.diagnostics[0].code,
1061            ProxyDiagnosticCode::OAuthTokenExchangeFailed
1062        );
1063    }
1064
1065    /// Build a test `TokenCache` with a pre-populated token.
1066    fn make_test_token_cache(token: &str, ttl: std::time::Duration) -> TokenCache {
1067        use crate::oauth2::OAuth2ExchangeConfig;
1068
1069        let config = OAuth2ExchangeConfig {
1070            token_url: "https://127.0.0.1:1/oauth/token".to_string(),
1071            client_id: Zeroizing::new("test-client".to_string()),
1072            client_secret: Zeroizing::new("test-secret".to_string()),
1073            scope: String::new(),
1074        };
1075
1076        TokenCache::new_from_parts(config, test_tls_connector(), token, ttl)
1077    }
1078}