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 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 let encoded =
161 base64::engine::general_purpose::STANDARD.encode(secret.as_bytes());
162 Zeroizing::new(format!("Basic {}", encoded))
163 }
164 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 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 #[must_use]
285 pub fn empty() -> Self {
286 Self {
287 credentials: HashMap::new(),
288 oauth2_routes: HashMap::new(),
289 }
290 }
291
292 #[must_use]
294 pub fn get(&self, prefix: &str) -> Option<&LoadedCredential> {
295 self.credentials.get(prefix)
296 }
297
298 #[must_use]
300 pub fn get_oauth2(&self, prefix: &str) -> Option<&OAuth2Route> {
301 self.oauth2_routes.get(prefix)
302 }
303
304 #[must_use]
306 pub fn is_empty(&self) -> bool {
307 self.credentials.is_empty() && self.oauth2_routes.is_empty()
308 }
309
310 #[must_use]
312 pub fn len(&self) -> usize {
313 self.credentials.len() + self.oauth2_routes.len()
314 }
315
316 #[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
328const KEYRING_SERVICE: &str = nono::keystore::DEFAULT_SERVICE;
331
332fn 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
352fn build_credential_miss_hint(key: &str) -> String {
365 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 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 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 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 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 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 #[test]
480 fn test_miss_hint_env_uri_with_keyring_fallback_message() {
481 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 #[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 #[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 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 assert!(
541 debug_output.contains("[REDACTED]"),
542 "Debug output should contain [REDACTED], got: {}",
543 debug_output
544 );
545 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 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 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 token_url: "https://127.0.0.1:1/oauth/token".to_string(),
732 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 assert!(
743 store.is_ok(),
744 "load should not fail on unreachable OAuth2 endpoint"
745 );
746 let store = store.unwrap();
747
748 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 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}