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> {
113 let mut credentials = HashMap::new();
114 let mut oauth2_routes = HashMap::new();
115
116 for route in routes {
117 let normalized_prefix = route.prefix.trim_matches('/').to_string();
121 if let Some(ref key) = route.credential_key {
122 debug!(
123 "Loading credential for route prefix: {} (mode: {:?})",
124 normalized_prefix, route.inject_mode
125 );
126
127 let secret = match nono::keystore::load_secret_by_ref(KEYRING_SERVICE, key) {
128 Ok(s) => s,
129 Err(nono::NonoError::SecretNotFound(_)) => {
130 let hint = build_credential_miss_hint(key);
131 warn!(
132 "Credential '{}' not found for route '{}' — managed-credential requests on this route will be denied until the credential is available.{}",
133 key, normalized_prefix, hint
134 );
135 continue;
136 }
137 Err(e) => return Err(ProxyError::Credential(e.to_string())),
138 };
139
140 let effective_format = if route.inject_header != "Authorization"
146 && route.credential_format == "Bearer {}"
147 {
148 "{}".to_string()
149 } else {
150 route.credential_format.clone()
151 };
152
153 let header_value = match route.inject_mode {
154 InjectMode::Header => Zeroizing::new(effective_format.replace("{}", &secret)),
155 InjectMode::BasicAuth => {
156 let encoded =
158 base64::engine::general_purpose::STANDARD.encode(secret.as_bytes());
159 Zeroizing::new(format!("Basic {}", encoded))
160 }
161 InjectMode::UrlPath | InjectMode::QueryParam => Zeroizing::new(String::new()),
163 };
164
165 credentials.insert(
166 normalized_prefix.clone(),
167 LoadedCredential {
168 inject_mode: route.inject_mode.clone(),
169 proxy_inject_mode: route
170 .proxy
171 .as_ref()
172 .and_then(|p| p.inject_mode.clone())
173 .unwrap_or_else(|| route.inject_mode.clone()),
174 raw_credential: secret,
175 header_name: route.inject_header.clone(),
176 proxy_header_name: route
177 .proxy
178 .as_ref()
179 .and_then(|p| p.inject_header.clone())
180 .unwrap_or_else(|| route.inject_header.clone()),
181 header_value,
182 path_pattern: route.path_pattern.clone(),
183 proxy_path_pattern: route
184 .proxy
185 .as_ref()
186 .and_then(|p| p.path_pattern.clone())
187 .or_else(|| route.path_pattern.clone()),
188 path_replacement: route.path_replacement.clone(),
189 query_param_name: route.query_param_name.clone(),
190 proxy_query_param_name: route
191 .proxy
192 .as_ref()
193 .and_then(|p| p.query_param_name.clone())
194 .or_else(|| route.query_param_name.clone()),
195 },
196 );
197 continue;
198 }
199
200 if let Some(ref oauth2) = route.oauth2 {
202 debug!(
203 "Loading OAuth2 credential for route prefix: {}",
204 route.prefix
205 );
206
207 let client_id =
208 match nono::keystore::load_secret_by_ref(KEYRING_SERVICE, &oauth2.client_id) {
209 Ok(s) => s,
210 Err(nono::NonoError::SecretNotFound(msg)) => {
211 warn!(
212 "OAuth2 client_id not available for route '{}': {}. \
213 Managed-credential requests on this route will be denied.",
214 route.prefix, msg
215 );
216 continue;
217 }
218 Err(e) => return Err(ProxyError::Credential(e.to_string())),
219 };
220
221 let client_secret = match nono::keystore::load_secret_by_ref(
222 KEYRING_SERVICE,
223 &oauth2.client_secret,
224 ) {
225 Ok(s) => s,
226 Err(nono::NonoError::SecretNotFound(msg)) => {
227 warn!(
228 "OAuth2 client_secret not available for route '{}': {}. \
229 Managed-credential requests on this route will be denied.",
230 route.prefix, msg
231 );
232 continue;
233 }
234 Err(e) => return Err(ProxyError::Credential(e.to_string())),
235 };
236
237 let config = OAuth2ExchangeConfig {
238 token_url: oauth2.token_url.clone(),
239 client_id,
240 client_secret,
241 scope: oauth2.scope.clone(),
242 };
243
244 match TokenCache::new(config, tls_connector.clone()) {
245 Ok(cache) => {
246 oauth2_routes.insert(
247 route.prefix.clone(),
248 OAuth2Route {
249 cache,
250 upstream: route.upstream.clone(),
251 },
252 );
253 }
254 Err(e) => {
255 warn!(
256 "OAuth2 token exchange failed for route '{}': {}. \
257 Managed-credential requests on this route will be denied.",
258 route.prefix, e
259 );
260 continue;
261 }
262 }
263 }
264 }
265
266 Ok(Self {
267 credentials,
268 oauth2_routes,
269 })
270 }
271
272 #[must_use]
274 pub fn empty() -> Self {
275 Self {
276 credentials: HashMap::new(),
277 oauth2_routes: HashMap::new(),
278 }
279 }
280
281 #[must_use]
283 pub fn get(&self, prefix: &str) -> Option<&LoadedCredential> {
284 self.credentials.get(prefix)
285 }
286
287 #[must_use]
289 pub fn get_oauth2(&self, prefix: &str) -> Option<&OAuth2Route> {
290 self.oauth2_routes.get(prefix)
291 }
292
293 #[must_use]
295 pub fn is_empty(&self) -> bool {
296 self.credentials.is_empty() && self.oauth2_routes.is_empty()
297 }
298
299 #[must_use]
301 pub fn len(&self) -> usize {
302 self.credentials.len() + self.oauth2_routes.len()
303 }
304
305 #[must_use]
308 pub fn loaded_prefixes(&self) -> std::collections::HashSet<String> {
309 self.credentials
310 .keys()
311 .chain(self.oauth2_routes.keys())
312 .cloned()
313 .collect()
314 }
315}
316
317const KEYRING_SERVICE: &str = nono::keystore::DEFAULT_SERVICE;
320
321fn build_credential_miss_hint(key: &str) -> String {
334 if let Some(var) = key.strip_prefix("env://") {
337 if nono::keystore::load_secret_by_ref(KEYRING_SERVICE, var).is_ok() {
338 return format!(
339 " Tip: a keyring entry exists for '{}'. Change credential_key to bare \
340 '{}' (no env:// prefix) to use the keyring, or set the env var.",
341 var, var
342 );
343 }
344 return format!(
345 " Looked for env var '{}' (not set). To add to the macOS keychain: \
346 security add-generic-password -s \"nono\" -a \"{}\" -w — and set credential_key \
347 to bare '{}' (no env:// prefix).",
348 var, var, var
349 );
350 }
351
352 if !key.contains("://") {
355 if std::env::var_os(key).is_some() {
356 return format!(
357 " Tip: env var '{}' is set on the host. Change credential_key to \
358 'env://{}' to use it, or add a keyring entry for '{}'.",
359 key, key, key
360 );
361 }
362 if cfg!(target_os = "macos") {
363 return format!(
364 " To add it to the macOS keychain: security add-generic-password \
365 -s \"nono\" -a \"{}\" -w",
366 key
367 );
368 }
369 }
370
371 String::new()
375}
376
377#[cfg(test)]
378#[allow(clippy::unwrap_used)]
379mod tests {
380 use super::*;
381 use std::sync::{Arc, Mutex};
382
383 static ENV_LOCK: Mutex<()> = Mutex::new(());
384
385 struct EnvVarGuard {
386 original: Vec<(&'static str, Option<String>)>,
387 }
388
389 #[allow(clippy::disallowed_methods)]
390 impl EnvVarGuard {
391 fn set_all(vars: &[(&'static str, &str)]) -> Self {
392 let original = vars
393 .iter()
394 .map(|(key, _)| (*key, std::env::var(key).ok()))
395 .collect::<Vec<_>>();
396
397 for (key, value) in vars {
398 std::env::set_var(key, value);
399 }
400
401 Self { original }
402 }
403 }
404
405 #[allow(clippy::disallowed_methods)]
406 impl Drop for EnvVarGuard {
407 fn drop(&mut self) {
408 for (key, value) in self.original.iter().rev() {
409 match value {
410 Some(value) => std::env::set_var(key, value),
411 None => std::env::remove_var(key),
412 }
413 }
414 }
415 }
416
417 fn test_tls_connector() -> TlsConnector {
419 let mut root_store = rustls::RootCertStore::empty();
420 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
421 let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(
422 rustls::crypto::ring::default_provider(),
423 ))
424 .with_safe_default_protocol_versions()
425 .unwrap()
426 .with_root_certificates(root_store)
427 .with_no_client_auth();
428 TlsConnector::from(Arc::new(tls_config))
429 }
430
431 #[test]
432 fn test_empty_credential_store() {
433 let store = CredentialStore::empty();
434 assert!(store.is_empty());
435 assert_eq!(store.len(), 0);
436 assert!(store.get("openai").is_none());
437 assert!(store.get("/openai").is_none());
438 assert!(store.get_oauth2("/openai").is_none());
439 }
440
441 #[test]
446 fn test_miss_hint_env_uri_with_keyring_fallback_message() {
447 let hint = build_credential_miss_hint("env://NONONO_TEST_MISSING_VAR");
451 assert!(
452 hint.contains("NONONO_TEST_MISSING_VAR"),
453 "hint should name the missing variable, got: {}",
454 hint
455 );
456 }
457
458 #[test]
461 fn test_miss_hint_bare_key_with_env_var_set() {
462 let _lock = ENV_LOCK.lock().expect("env mutex poisoned");
463 let _guard = EnvVarGuard::set_all(&[("NONONO_TEST_BARE_KEY", "secret-value")]);
464
465 let hint = build_credential_miss_hint("NONONO_TEST_BARE_KEY");
466 assert!(
467 hint.contains("env://NONONO_TEST_BARE_KEY"),
468 "hint should suggest env:// URI, got: {}",
469 hint
470 );
471 }
472
473 #[test]
475 fn test_miss_hint_op_uri_returns_empty() {
476 let hint = build_credential_miss_hint("op://Vault/Item/field");
477 assert!(
478 hint.is_empty(),
479 "URI-managed sources should not get cross-probe hints, got: {}",
480 hint
481 );
482 }
483
484 #[test]
485 fn test_loaded_credential_debug_redacts_secrets() {
486 let cred = LoadedCredential {
490 inject_mode: InjectMode::Header,
491 proxy_inject_mode: InjectMode::Header,
492 raw_credential: Zeroizing::new("sk-secret-12345".to_string()),
493 header_name: "Authorization".to_string(),
494 proxy_header_name: "Authorization".to_string(),
495 header_value: Zeroizing::new("Bearer sk-secret-12345".to_string()),
496 path_pattern: None,
497 proxy_path_pattern: None,
498 path_replacement: None,
499 query_param_name: None,
500 proxy_query_param_name: None,
501 };
502
503 let debug_output = format!("{:?}", cred);
504
505 assert!(
507 debug_output.contains("[REDACTED]"),
508 "Debug output should contain [REDACTED], got: {}",
509 debug_output
510 );
511 assert!(
513 !debug_output.contains("sk-secret-12345"),
514 "Debug output must not contain the real secret"
515 );
516 assert!(
517 !debug_output.contains("Bearer sk-secret"),
518 "Debug output must not contain the formatted secret"
519 );
520 assert!(debug_output.contains("Authorization"));
522 }
523
524 #[test]
525 fn test_load_no_credential_routes() {
526 let tls = test_tls_connector();
527 let routes = vec![RouteConfig {
528 prefix: "/test".to_string(),
529 upstream: "https://example.com".to_string(),
530 credential_key: None,
531 inject_mode: InjectMode::Header,
532 inject_header: "Authorization".to_string(),
533 credential_format: "Bearer {}".to_string(),
534 path_pattern: None,
535 path_replacement: None,
536 query_param_name: None,
537 proxy: None,
538 env_var: None,
539 endpoint_rules: vec![],
540 tls_ca: None,
541 tls_client_cert: None,
542 tls_client_key: None,
543 oauth2: None,
544 }];
545 let store = CredentialStore::load(&routes, &tls);
546 assert!(store.is_ok());
547 let store = store.unwrap_or_else(|_| CredentialStore::empty());
548 assert!(store.is_empty());
549 }
550
551 #[test]
552 fn test_get_oauth2_returns_none_for_non_oauth2_routes() {
553 let store = CredentialStore::empty();
554 assert!(store.get_oauth2("openai").is_none());
555 assert!(store.get_oauth2("my-api").is_none());
556 }
557
558 #[test]
559 fn test_is_empty_false_with_only_oauth2_routes() {
560 use std::time::Duration;
564
565 let cache = make_test_token_cache("test-token", Duration::from_secs(3600));
566 let mut oauth2_routes = HashMap::new();
567 oauth2_routes.insert(
568 "my-api".to_string(),
569 OAuth2Route {
570 cache,
571 upstream: "https://api.example.com".to_string(),
572 },
573 );
574
575 let store = CredentialStore {
576 credentials: HashMap::new(),
577 oauth2_routes,
578 };
579
580 assert!(
581 !store.is_empty(),
582 "store with OAuth2 routes should not be empty"
583 );
584 assert_eq!(store.len(), 1);
585 assert!(store.get_oauth2("my-api").is_some());
586 assert!(store.get("my-api").is_none());
587 }
588
589 #[test]
590 fn test_loaded_prefixes_includes_oauth2() {
591 use std::time::Duration;
592
593 let cache = make_test_token_cache("test-token", Duration::from_secs(3600));
594 let mut oauth2_routes = HashMap::new();
595 oauth2_routes.insert(
596 "my-api".to_string(),
597 OAuth2Route {
598 cache,
599 upstream: "https://api.example.com".to_string(),
600 },
601 );
602
603 let store = CredentialStore {
604 credentials: HashMap::new(),
605 oauth2_routes,
606 };
607
608 let prefixes = store.loaded_prefixes();
609 assert!(prefixes.contains("my-api"));
610 }
611
612 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
613 async fn test_load_oauth2_unreachable_endpoint_skips_route() {
614 use crate::config::OAuth2Config;
615
616 let _lock = ENV_LOCK.lock().unwrap();
617 let _env = EnvVarGuard::set_all(&[
618 ("TEST_OAUTH2_CLIENT_ID", "test-client"),
619 ("TEST_OAUTH2_CLIENT_SECRET", "test-secret"),
620 ]);
621 let tls = test_tls_connector();
622 let routes = vec![RouteConfig {
623 prefix: "my-api".to_string(),
624 upstream: "https://api.example.com".to_string(),
625 credential_key: None,
626 inject_mode: InjectMode::Header,
627 inject_header: "Authorization".to_string(),
628 credential_format: "Bearer {}".to_string(),
629 path_pattern: None,
630 path_replacement: None,
631 query_param_name: None,
632 proxy: None,
633 env_var: Some("MY_API_KEY".to_string()),
634 endpoint_rules: vec![],
635 tls_ca: None,
636 tls_client_cert: None,
637 tls_client_key: None,
638 oauth2: Some(OAuth2Config {
639 token_url: "https://127.0.0.1:1/oauth/token".to_string(),
641 client_id: "env://TEST_OAUTH2_CLIENT_ID".to_string(),
643 client_secret: "env://TEST_OAUTH2_CLIENT_SECRET".to_string(),
644 scope: String::new(),
645 }),
646 }];
647
648 let store = CredentialStore::load(&routes, &tls);
649
650 assert!(
652 store.is_ok(),
653 "load should not fail on unreachable OAuth2 endpoint"
654 );
655 let store = store.unwrap();
656
657 assert!(
659 store.is_empty(),
660 "unreachable OAuth2 endpoint should result in skipped route"
661 );
662 assert!(store.get_oauth2("my-api").is_none());
663 }
664
665 fn make_test_token_cache(token: &str, ttl: std::time::Duration) -> TokenCache {
667 use crate::oauth2::OAuth2ExchangeConfig;
668
669 let config = OAuth2ExchangeConfig {
670 token_url: "https://127.0.0.1:1/oauth/token".to_string(),
671 client_id: Zeroizing::new("test-client".to_string()),
672 client_secret: Zeroizing::new("test-secret".to_string()),
673 scope: String::new(),
674 };
675
676 TokenCache::new_from_parts(config, test_tls_connector(), token, ttl)
677 }
678}