1use 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
22pub struct LoadedCredential {
28 pub inject_mode: InjectMode,
30 pub proxy_inject_mode: InjectMode,
32 pub raw_credential: Zeroizing<String>,
34
35 pub header_name: String,
38 pub proxy_header_name: String,
40 pub header_value: Zeroizing<String>,
42
43 pub path_pattern: Option<String>,
46 pub proxy_path_pattern: Option<String>,
48 pub path_replacement: Option<String>,
50
51 pub query_param_name: Option<String>,
54 pub proxy_query_param_name: Option<String>,
56}
57
58impl 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#[derive(Debug)]
80pub struct OAuth2Route {
81 pub cache: TokenCache,
83 pub upstream: String,
85}
86
87#[derive(Debug)]
89pub struct CredentialLoadOutcome {
90 pub store: CredentialStore,
92 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#[derive(Debug)]
105pub struct CredentialStore {
106 credentials: HashMap<String, LoadedCredential>,
108 oauth2_routes: HashMap<String, OAuth2Route>,
110 aws_routes: HashMap<String, ()>,
114}
115
116impl CredentialStore {
117 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 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 let encoded =
201 base64::engine::general_purpose::STANDARD.encode(secret.as_bytes());
202 Zeroizing::new(format!("Basic {}", encoded))
203 }
204 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 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_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(
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 #[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 #[must_use]
345 pub fn get(&self, prefix: &str) -> Option<&LoadedCredential> {
346 self.credentials.get(prefix)
347 }
348
349 #[must_use]
351 pub fn get_oauth2(&self, prefix: &str) -> Option<&OAuth2Route> {
352 self.oauth2_routes.get(prefix)
353 }
354
355 #[must_use]
360 pub fn get_aws(&self, prefix: &str) -> Option<&()> {
361 self.aws_routes.get(prefix)
362 }
363
364 #[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 #[must_use]
372 pub fn len(&self) -> usize {
373 self.credentials.len() + self.oauth2_routes.len() + self.aws_routes.len()
374 }
375
376 #[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
389const 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
411fn 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 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
487fn 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
496fn build_credential_miss_hint(key: &str) -> String {
509 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 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 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 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 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 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 #[test]
624 fn test_miss_hint_env_uri_with_keyring_fallback_message() {
625 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 #[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 #[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 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 assert!(
685 debug_output.contains("[REDACTED]"),
686 "Debug output should contain [REDACTED], got: {}",
687 debug_output
688 );
689 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 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 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 token_url: "https://127.0.0.1:1/oauth/token".to_string(),
1034 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 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 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 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}