1use 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
21pub struct LoadedCredential {
27 pub inject_mode: InjectMode,
29 pub proxy_inject_mode: InjectMode,
31 pub raw_credential: Zeroizing<String>,
33
34 pub header_name: String,
37 pub proxy_header_name: String,
39 pub header_value: Zeroizing<String>,
41
42 pub path_pattern: Option<String>,
45 pub proxy_path_pattern: Option<String>,
47 pub path_replacement: Option<String>,
49
50 pub query_param_name: Option<String>,
53 pub proxy_query_param_name: Option<String>,
55}
56
57impl 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#[derive(Debug)]
79pub struct OAuth2Route {
80 pub cache: TokenCache,
82 pub upstream: String,
84}
85
86#[derive(Debug)]
88pub struct CredentialStore {
89 credentials: HashMap<String, LoadedCredential>,
91 oauth2_routes: HashMap<String, OAuth2Route>,
93}
94
95impl CredentialStore {
96 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 let normalized_prefix = route.prefix.trim_matches('/').to_string();
122 if let Some(ref key) = route.credential_key {
123 debug!(
124 "Loading credential for route prefix: {} (mode: {:?})",
125 normalized_prefix, route.inject_mode
126 );
127
128 let secret = match nono::keystore::load_secret_by_ref(KEYRING_SERVICE, key) {
129 Ok(s) => s,
130 Err(nono::NonoError::SecretNotFound(_)) => {
131 let hint = build_credential_miss_hint(key);
132 warn!(
133 "Credential '{}' not found for route '{}' — managed-credential requests on this route will be denied until the credential is available.{}",
134 key, normalized_prefix, hint
135 );
136 continue;
137 }
138 Err(nono::NonoError::KeystoreAccess(msg)) => {
139 warn!(
140 "Credential '{}' not available for route '{}': {}. \
141 Managed-credential requests on this route will be denied until the credential is available.",
142 key, normalized_prefix, msg
143 );
144 continue;
145 }
146 Err(e) => return Err(ProxyError::Credential(e.to_string())),
147 };
148
149 let effective_format = crate::config::resolved_credential_format(
150 route.inject_header.as_str(),
151 route.credential_format.as_deref(),
152 );
153
154 let header_value = match route.inject_mode {
155 InjectMode::Header => Zeroizing::new(effective_format.replace("{}", &secret)),
156 InjectMode::BasicAuth => {
157 let encoded =
159 base64::engine::general_purpose::STANDARD.encode(secret.as_bytes());
160 Zeroizing::new(format!("Basic {}", encoded))
161 }
162 InjectMode::UrlPath | InjectMode::QueryParam => Zeroizing::new(String::new()),
164 };
165
166 credentials.insert(
167 normalized_prefix.clone(),
168 LoadedCredential {
169 inject_mode: route.inject_mode.clone(),
170 proxy_inject_mode: route
171 .proxy
172 .as_ref()
173 .and_then(|p| p.inject_mode.clone())
174 .unwrap_or_else(|| route.inject_mode.clone()),
175 raw_credential: secret,
176 header_name: route.inject_header.clone(),
177 proxy_header_name: route
178 .proxy
179 .as_ref()
180 .and_then(|p| p.inject_header.clone())
181 .unwrap_or_else(|| route.inject_header.clone()),
182 header_value,
183 path_pattern: route.path_pattern.clone(),
184 proxy_path_pattern: route
185 .proxy
186 .as_ref()
187 .and_then(|p| p.path_pattern.clone())
188 .or_else(|| route.path_pattern.clone()),
189 path_replacement: route.path_replacement.clone(),
190 query_param_name: route.query_param_name.clone(),
191 proxy_query_param_name: route
192 .proxy
193 .as_ref()
194 .and_then(|p| p.query_param_name.clone())
195 .or_else(|| route.query_param_name.clone()),
196 },
197 );
198 continue;
199 }
200
201 if let Some(ref oauth2) = route.oauth2 {
203 debug!(
204 "Loading OAuth2 credential for route prefix: {}",
205 route.prefix
206 );
207
208 let client_id =
209 match nono::keystore::load_secret_by_ref(KEYRING_SERVICE, &oauth2.client_id) {
210 Ok(s) => s,
211 Err(nono::NonoError::SecretNotFound(msg))
212 | Err(nono::NonoError::KeystoreAccess(msg)) => {
213 warn!(
214 "OAuth2 client_id not available for route '{}': {}. \
215 Managed-credential requests on this route will be denied.",
216 route.prefix, msg
217 );
218 continue;
219 }
220 Err(e) => return Err(ProxyError::Credential(e.to_string())),
221 };
222
223 let client_secret = match nono::keystore::load_secret_by_ref(
224 KEYRING_SERVICE,
225 &oauth2.client_secret,
226 ) {
227 Ok(s) => s,
228 Err(nono::NonoError::SecretNotFound(msg))
229 | Err(nono::NonoError::KeystoreAccess(msg)) => {
230 warn!(
231 "OAuth2 client_secret not available for route '{}': {}. \
232 Managed-credential requests on this route will be denied.",
233 route.prefix, msg
234 );
235 continue;
236 }
237 Err(e) => return Err(ProxyError::Credential(e.to_string())),
238 };
239
240 let config = OAuth2ExchangeConfig {
241 token_url: oauth2.token_url.clone(),
242 client_id,
243 client_secret,
244 scope: oauth2.scope.clone(),
245 };
246
247 match TokenCache::new(config, tls_connector.clone()) {
248 Ok(cache) => {
249 oauth2_routes.insert(
250 route.prefix.clone(),
251 OAuth2Route {
252 cache,
253 upstream: route.upstream.clone(),
254 },
255 );
256 }
257 Err(e) => {
258 warn!(
259 "OAuth2 token exchange failed for route '{}': {}. \
260 Managed-credential requests on this route will be denied.",
261 route.prefix, e
262 );
263 continue;
264 }
265 }
266 }
267 }
268
269 Ok(Self {
270 credentials,
271 oauth2_routes,
272 })
273 }
274
275 #[must_use]
277 pub fn empty() -> Self {
278 Self {
279 credentials: HashMap::new(),
280 oauth2_routes: HashMap::new(),
281 }
282 }
283
284 #[must_use]
286 pub fn get(&self, prefix: &str) -> Option<&LoadedCredential> {
287 self.credentials.get(prefix)
288 }
289
290 #[must_use]
292 pub fn get_oauth2(&self, prefix: &str) -> Option<&OAuth2Route> {
293 self.oauth2_routes.get(prefix)
294 }
295
296 #[must_use]
298 pub fn is_empty(&self) -> bool {
299 self.credentials.is_empty() && self.oauth2_routes.is_empty()
300 }
301
302 #[must_use]
304 pub fn len(&self) -> usize {
305 self.credentials.len() + self.oauth2_routes.len()
306 }
307
308 #[must_use]
311 pub fn loaded_prefixes(&self) -> std::collections::HashSet<String> {
312 self.credentials
313 .keys()
314 .chain(self.oauth2_routes.keys())
315 .cloned()
316 .collect()
317 }
318}
319
320const KEYRING_SERVICE: &str = nono::keystore::DEFAULT_SERVICE;
323
324fn build_credential_miss_hint(key: &str) -> String {
337 if let Some(var) = key.strip_prefix("env://") {
340 if nono::keystore::load_secret_by_ref(KEYRING_SERVICE, var).is_ok() {
341 return format!(
342 " Tip: a keyring entry exists for '{}'. Change credential_key to bare \
343 '{}' (no env:// prefix) to use the keyring, or set the env var.",
344 var, var
345 );
346 }
347 return format!(
348 " Looked for env var '{}' (not set). To add to the macOS keychain: \
349 security add-generic-password -s \"nono\" -a \"{}\" -w — and set credential_key \
350 to bare '{}' (no env:// prefix).",
351 var, var, var
352 );
353 }
354
355 if !key.contains("://") {
358 if std::env::var_os(key).is_some() {
359 return format!(
360 " Tip: env var '{}' is set on the host. Change credential_key to \
361 'env://{}' to use it, or add a keyring entry for '{}'.",
362 key, key, key
363 );
364 }
365 if cfg!(target_os = "macos") {
366 return format!(
367 " To add it to the macOS keychain: security add-generic-password \
368 -s \"nono\" -a \"{}\" -w",
369 key
370 );
371 }
372 }
373
374 String::new()
378}
379
380#[cfg(test)]
381#[allow(clippy::unwrap_used)]
382mod tests {
383 use super::*;
384 use std::sync::{Arc, Mutex};
385
386 static ENV_LOCK: Mutex<()> = Mutex::new(());
387
388 struct EnvVarGuard {
389 original: Vec<(&'static str, Option<String>)>,
390 }
391
392 #[allow(clippy::disallowed_methods)]
393 impl EnvVarGuard {
394 fn set_all(vars: &[(&'static str, &str)]) -> Self {
395 let original = vars
396 .iter()
397 .map(|(key, _)| (*key, std::env::var(key).ok()))
398 .collect::<Vec<_>>();
399
400 for (key, value) in vars {
401 unsafe { std::env::set_var(key, value) };
404 }
405
406 Self { original }
407 }
408 }
409
410 #[allow(clippy::disallowed_methods)]
411 impl Drop for EnvVarGuard {
412 fn drop(&mut self) {
413 for (key, value) in self.original.iter().rev() {
414 match value {
416 Some(value) => unsafe { std::env::set_var(key, value) },
417 None => unsafe { std::env::remove_var(key) },
418 }
419 }
420 }
421 }
422
423 fn test_tls_connector() -> TlsConnector {
425 let mut root_store = rustls::RootCertStore::empty();
426 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
427 let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(
428 rustls::crypto::ring::default_provider(),
429 ))
430 .with_safe_default_protocol_versions()
431 .unwrap()
432 .with_root_certificates(root_store)
433 .with_no_client_auth();
434 TlsConnector::from(Arc::new(tls_config))
435 }
436
437 #[test]
438 fn test_empty_credential_store() {
439 let store = CredentialStore::empty();
440 assert!(store.is_empty());
441 assert_eq!(store.len(), 0);
442 assert!(store.get("openai").is_none());
443 assert!(store.get("/openai").is_none());
444 assert!(store.get_oauth2("/openai").is_none());
445 }
446
447 #[test]
452 fn test_miss_hint_env_uri_with_keyring_fallback_message() {
453 let hint = build_credential_miss_hint("env://NONONO_TEST_MISSING_VAR");
457 assert!(
458 hint.contains("NONONO_TEST_MISSING_VAR"),
459 "hint should name the missing variable, got: {}",
460 hint
461 );
462 }
463
464 #[test]
467 fn test_miss_hint_bare_key_with_env_var_set() {
468 let _lock = ENV_LOCK.lock().expect("env mutex poisoned");
469 let _guard = EnvVarGuard::set_all(&[("NONONO_TEST_BARE_KEY", "secret-value")]);
470
471 let hint = build_credential_miss_hint("NONONO_TEST_BARE_KEY");
472 assert!(
473 hint.contains("env://NONONO_TEST_BARE_KEY"),
474 "hint should suggest env:// URI, got: {}",
475 hint
476 );
477 }
478
479 #[test]
481 fn test_miss_hint_op_uri_returns_empty() {
482 let hint = build_credential_miss_hint("op://Vault/Item/field");
483 assert!(
484 hint.is_empty(),
485 "URI-managed sources should not get cross-probe hints, got: {}",
486 hint
487 );
488 }
489
490 #[test]
491 fn test_loaded_credential_debug_redacts_secrets() {
492 let cred = LoadedCredential {
496 inject_mode: InjectMode::Header,
497 proxy_inject_mode: InjectMode::Header,
498 raw_credential: Zeroizing::new("sk-secret-12345".to_string()),
499 header_name: "Authorization".to_string(),
500 proxy_header_name: "Authorization".to_string(),
501 header_value: Zeroizing::new("Bearer sk-secret-12345".to_string()),
502 path_pattern: None,
503 proxy_path_pattern: None,
504 path_replacement: None,
505 query_param_name: None,
506 proxy_query_param_name: None,
507 };
508
509 let debug_output = format!("{:?}", cred);
510
511 assert!(
513 debug_output.contains("[REDACTED]"),
514 "Debug output should contain [REDACTED], got: {}",
515 debug_output
516 );
517 assert!(
519 !debug_output.contains("sk-secret-12345"),
520 "Debug output must not contain the real secret"
521 );
522 assert!(
523 !debug_output.contains("Bearer sk-secret"),
524 "Debug output must not contain the formatted secret"
525 );
526 assert!(debug_output.contains("Authorization"));
528 }
529
530 #[test]
531 fn test_load_no_credential_routes() {
532 let tls = test_tls_connector();
533 let routes = vec![RouteConfig {
534 prefix: "/test".to_string(),
535 upstream: "https://example.com".to_string(),
536 credential_key: None,
537 inject_mode: InjectMode::Header,
538 inject_header: "Authorization".to_string(),
539 credential_format: Some("Bearer {}".to_string()),
540 path_pattern: None,
541 path_replacement: None,
542 query_param_name: None,
543 proxy: None,
544 env_var: None,
545 endpoint_rules: vec![],
546 tls_ca: None,
547 tls_client_cert: None,
548 tls_client_key: None,
549 oauth2: None,
550 }];
551 let store = CredentialStore::load(&routes, &tls);
552 assert!(store.is_ok());
553 let store = store.unwrap_or_else(|_| CredentialStore::empty());
554 assert!(store.is_empty());
555 }
556
557 #[test]
558 fn test_get_oauth2_returns_none_for_non_oauth2_routes() {
559 let store = CredentialStore::empty();
560 assert!(store.get_oauth2("openai").is_none());
561 assert!(store.get_oauth2("my-api").is_none());
562 }
563
564 #[test]
565 fn test_is_empty_false_with_only_oauth2_routes() {
566 use std::time::Duration;
570
571 let cache = make_test_token_cache("test-token", Duration::from_secs(3600));
572 let mut oauth2_routes = HashMap::new();
573 oauth2_routes.insert(
574 "my-api".to_string(),
575 OAuth2Route {
576 cache,
577 upstream: "https://api.example.com".to_string(),
578 },
579 );
580
581 let store = CredentialStore {
582 credentials: HashMap::new(),
583 oauth2_routes,
584 };
585
586 assert!(
587 !store.is_empty(),
588 "store with OAuth2 routes should not be empty"
589 );
590 assert_eq!(store.len(), 1);
591 assert!(store.get_oauth2("my-api").is_some());
592 assert!(store.get("my-api").is_none());
593 }
594
595 #[test]
596 fn test_loaded_prefixes_includes_oauth2() {
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 let prefixes = store.loaded_prefixes();
615 assert!(prefixes.contains("my-api"));
616 }
617
618 #[test]
619 fn test_load_non_authorization_header_explicit_bearer_format() {
620 let _lock = ENV_LOCK.lock().expect("env mutex poisoned");
621 let _guard = EnvVarGuard::set_all(&[("NONO_PROXY_TEST_LITELLM_TOKEN", "sk-litellm-test")]);
622 let tls = test_tls_connector();
623 let routes = vec![RouteConfig {
624 prefix: "litellm".to_string(),
625 upstream: "https://litellm".to_string(),
626 credential_key: Some("env://NONO_PROXY_TEST_LITELLM_TOKEN".to_string()),
627 inject_mode: InjectMode::Header,
628 inject_header: "x-litellm-api-key".to_string(),
629 credential_format: Some("Bearer {}".to_string()),
630 path_pattern: None,
631 path_replacement: None,
632 query_param_name: None,
633 proxy: None,
634 env_var: None,
635 endpoint_rules: vec![],
636 tls_ca: None,
637 tls_client_cert: None,
638 tls_client_key: None,
639 oauth2: None,
640 }];
641 let store = CredentialStore::load(&routes, &tls).expect("credential load");
642 let cred = store.get("litellm").expect("route should be loaded");
643 assert_eq!(cred.header_name, "x-litellm-api-key");
644 assert_eq!(cred.header_value.as_str(), "Bearer sk-litellm-test");
645 }
646
647 #[test]
648 fn test_load_non_authorization_header_omitted_format_injects_bare_secret() {
649 let _lock = ENV_LOCK.lock().expect("env mutex poisoned");
650 let _guard = EnvVarGuard::set_all(&[("NONO_PROXY_TEST_API_KEY", "secret-key")]);
651 let tls = test_tls_connector();
652 let routes = vec![RouteConfig {
653 prefix: "api".to_string(),
654 upstream: "https://api.example.com".to_string(),
655 credential_key: Some("env://NONO_PROXY_TEST_API_KEY".to_string()),
656 inject_mode: InjectMode::Header,
657 inject_header: "x-api-key".to_string(),
658 credential_format: None,
659 path_pattern: None,
660 path_replacement: None,
661 query_param_name: None,
662 proxy: None,
663 env_var: None,
664 endpoint_rules: vec![],
665 tls_ca: None,
666 tls_client_cert: None,
667 tls_client_key: None,
668 oauth2: None,
669 }];
670 let store = CredentialStore::load(&routes, &tls).expect("credential load");
671 let cred = store.get("api").expect("route should be loaded");
672 assert_eq!(cred.header_value.as_str(), "secret-key");
673 }
674
675 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
676 async fn test_load_oauth2_unreachable_endpoint_skips_route() {
677 use crate::config::OAuth2Config;
678
679 let _lock = ENV_LOCK.lock().unwrap();
680 let _env = EnvVarGuard::set_all(&[
681 ("TEST_OAUTH2_CLIENT_ID", "test-client"),
682 ("TEST_OAUTH2_CLIENT_SECRET", "test-secret"),
683 ]);
684 let tls = test_tls_connector();
685 let routes = vec![RouteConfig {
686 prefix: "my-api".to_string(),
687 upstream: "https://api.example.com".to_string(),
688 credential_key: None,
689 inject_mode: InjectMode::Header,
690 inject_header: "Authorization".to_string(),
691 credential_format: Some("Bearer {}".to_string()),
692 path_pattern: None,
693 path_replacement: None,
694 query_param_name: None,
695 proxy: None,
696 env_var: Some("MY_API_KEY".to_string()),
697 endpoint_rules: vec![],
698 tls_ca: None,
699 tls_client_cert: None,
700 tls_client_key: None,
701 oauth2: Some(OAuth2Config {
702 token_url: "https://127.0.0.1:1/oauth/token".to_string(),
704 client_id: "env://TEST_OAUTH2_CLIENT_ID".to_string(),
706 client_secret: "env://TEST_OAUTH2_CLIENT_SECRET".to_string(),
707 scope: String::new(),
708 }),
709 }];
710
711 let store = CredentialStore::load(&routes, &tls);
712
713 assert!(
715 store.is_ok(),
716 "load should not fail on unreachable OAuth2 endpoint"
717 );
718 let store = store.unwrap();
719
720 assert!(
722 store.is_empty(),
723 "unreachable OAuth2 endpoint should result in skipped route"
724 );
725 assert!(store.get_oauth2("my-api").is_none());
726 }
727
728 fn make_test_token_cache(token: &str, ttl: std::time::Duration) -> TokenCache {
730 use crate::oauth2::OAuth2ExchangeConfig;
731
732 let config = OAuth2ExchangeConfig {
733 token_url: "https://127.0.0.1:1/oauth/token".to_string(),
734 client_id: Zeroizing::new("test-client".to_string()),
735 client_secret: Zeroizing::new("test-secret".to_string()),
736 scope: String::new(),
737 };
738
739 TokenCache::new_from_parts(config, test_tls_connector(), token, ttl)
740 }
741}