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 = if route.inject_header != "Authorization"
155 && route.credential_format == "Bearer {}"
156 {
157 "{}".to_string()
158 } else {
159 route.credential_format.clone()
160 };
161
162 let header_value = match route.inject_mode {
163 InjectMode::Header => Zeroizing::new(effective_format.replace("{}", &secret)),
164 InjectMode::BasicAuth => {
165 let encoded =
167 base64::engine::general_purpose::STANDARD.encode(secret.as_bytes());
168 Zeroizing::new(format!("Basic {}", encoded))
169 }
170 InjectMode::UrlPath | InjectMode::QueryParam => Zeroizing::new(String::new()),
172 };
173
174 credentials.insert(
175 normalized_prefix.clone(),
176 LoadedCredential {
177 inject_mode: route.inject_mode.clone(),
178 proxy_inject_mode: route
179 .proxy
180 .as_ref()
181 .and_then(|p| p.inject_mode.clone())
182 .unwrap_or_else(|| route.inject_mode.clone()),
183 raw_credential: secret,
184 header_name: route.inject_header.clone(),
185 proxy_header_name: route
186 .proxy
187 .as_ref()
188 .and_then(|p| p.inject_header.clone())
189 .unwrap_or_else(|| route.inject_header.clone()),
190 header_value,
191 path_pattern: route.path_pattern.clone(),
192 proxy_path_pattern: route
193 .proxy
194 .as_ref()
195 .and_then(|p| p.path_pattern.clone())
196 .or_else(|| route.path_pattern.clone()),
197 path_replacement: route.path_replacement.clone(),
198 query_param_name: route.query_param_name.clone(),
199 proxy_query_param_name: route
200 .proxy
201 .as_ref()
202 .and_then(|p| p.query_param_name.clone())
203 .or_else(|| route.query_param_name.clone()),
204 },
205 );
206 continue;
207 }
208
209 if let Some(ref oauth2) = route.oauth2 {
211 debug!(
212 "Loading OAuth2 credential for route prefix: {}",
213 route.prefix
214 );
215
216 let client_id =
217 match nono::keystore::load_secret_by_ref(KEYRING_SERVICE, &oauth2.client_id) {
218 Ok(s) => s,
219 Err(nono::NonoError::SecretNotFound(msg))
220 | Err(nono::NonoError::KeystoreAccess(msg)) => {
221 warn!(
222 "OAuth2 client_id not available for route '{}': {}. \
223 Managed-credential requests on this route will be denied.",
224 route.prefix, msg
225 );
226 continue;
227 }
228 Err(e) => return Err(ProxyError::Credential(e.to_string())),
229 };
230
231 let client_secret = match nono::keystore::load_secret_by_ref(
232 KEYRING_SERVICE,
233 &oauth2.client_secret,
234 ) {
235 Ok(s) => s,
236 Err(nono::NonoError::SecretNotFound(msg))
237 | Err(nono::NonoError::KeystoreAccess(msg)) => {
238 warn!(
239 "OAuth2 client_secret not available for route '{}': {}. \
240 Managed-credential requests on this route will be denied.",
241 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 build_credential_miss_hint(key: &str) -> String {
345 if let Some(var) = key.strip_prefix("env://") {
348 if nono::keystore::load_secret_by_ref(KEYRING_SERVICE, var).is_ok() {
349 return format!(
350 " Tip: a keyring entry exists for '{}'. Change credential_key to bare \
351 '{}' (no env:// prefix) to use the keyring, or set the env var.",
352 var, var
353 );
354 }
355 return format!(
356 " Looked for env var '{}' (not set). To add to the macOS keychain: \
357 security add-generic-password -s \"nono\" -a \"{}\" -w — and set credential_key \
358 to bare '{}' (no env:// prefix).",
359 var, var, var
360 );
361 }
362
363 if !key.contains("://") {
366 if std::env::var_os(key).is_some() {
367 return format!(
368 " Tip: env var '{}' is set on the host. Change credential_key to \
369 'env://{}' to use it, or add a keyring entry for '{}'.",
370 key, key, key
371 );
372 }
373 if cfg!(target_os = "macos") {
374 return format!(
375 " To add it to the macOS keychain: security add-generic-password \
376 -s \"nono\" -a \"{}\" -w",
377 key
378 );
379 }
380 }
381
382 String::new()
386}
387
388#[cfg(test)]
389#[allow(clippy::unwrap_used)]
390mod tests {
391 use super::*;
392 use std::sync::{Arc, Mutex};
393
394 static ENV_LOCK: Mutex<()> = Mutex::new(());
395
396 struct EnvVarGuard {
397 original: Vec<(&'static str, Option<String>)>,
398 }
399
400 #[allow(clippy::disallowed_methods)]
401 impl EnvVarGuard {
402 fn set_all(vars: &[(&'static str, &str)]) -> Self {
403 let original = vars
404 .iter()
405 .map(|(key, _)| (*key, std::env::var(key).ok()))
406 .collect::<Vec<_>>();
407
408 for (key, value) in vars {
409 std::env::set_var(key, value);
410 }
411
412 Self { original }
413 }
414 }
415
416 #[allow(clippy::disallowed_methods)]
417 impl Drop for EnvVarGuard {
418 fn drop(&mut self) {
419 for (key, value) in self.original.iter().rev() {
420 match value {
421 Some(value) => std::env::set_var(key, value),
422 None => std::env::remove_var(key),
423 }
424 }
425 }
426 }
427
428 fn test_tls_connector() -> TlsConnector {
430 let mut root_store = rustls::RootCertStore::empty();
431 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
432 let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(
433 rustls::crypto::ring::default_provider(),
434 ))
435 .with_safe_default_protocol_versions()
436 .unwrap()
437 .with_root_certificates(root_store)
438 .with_no_client_auth();
439 TlsConnector::from(Arc::new(tls_config))
440 }
441
442 #[test]
443 fn test_empty_credential_store() {
444 let store = CredentialStore::empty();
445 assert!(store.is_empty());
446 assert_eq!(store.len(), 0);
447 assert!(store.get("openai").is_none());
448 assert!(store.get("/openai").is_none());
449 assert!(store.get_oauth2("/openai").is_none());
450 }
451
452 #[test]
457 fn test_miss_hint_env_uri_with_keyring_fallback_message() {
458 let hint = build_credential_miss_hint("env://NONONO_TEST_MISSING_VAR");
462 assert!(
463 hint.contains("NONONO_TEST_MISSING_VAR"),
464 "hint should name the missing variable, got: {}",
465 hint
466 );
467 }
468
469 #[test]
472 fn test_miss_hint_bare_key_with_env_var_set() {
473 let _lock = ENV_LOCK.lock().expect("env mutex poisoned");
474 let _guard = EnvVarGuard::set_all(&[("NONONO_TEST_BARE_KEY", "secret-value")]);
475
476 let hint = build_credential_miss_hint("NONONO_TEST_BARE_KEY");
477 assert!(
478 hint.contains("env://NONONO_TEST_BARE_KEY"),
479 "hint should suggest env:// URI, got: {}",
480 hint
481 );
482 }
483
484 #[test]
486 fn test_miss_hint_op_uri_returns_empty() {
487 let hint = build_credential_miss_hint("op://Vault/Item/field");
488 assert!(
489 hint.is_empty(),
490 "URI-managed sources should not get cross-probe hints, got: {}",
491 hint
492 );
493 }
494
495 #[test]
496 fn test_loaded_credential_debug_redacts_secrets() {
497 let cred = LoadedCredential {
501 inject_mode: InjectMode::Header,
502 proxy_inject_mode: InjectMode::Header,
503 raw_credential: Zeroizing::new("sk-secret-12345".to_string()),
504 header_name: "Authorization".to_string(),
505 proxy_header_name: "Authorization".to_string(),
506 header_value: Zeroizing::new("Bearer sk-secret-12345".to_string()),
507 path_pattern: None,
508 proxy_path_pattern: None,
509 path_replacement: None,
510 query_param_name: None,
511 proxy_query_param_name: None,
512 };
513
514 let debug_output = format!("{:?}", cred);
515
516 assert!(
518 debug_output.contains("[REDACTED]"),
519 "Debug output should contain [REDACTED], got: {}",
520 debug_output
521 );
522 assert!(
524 !debug_output.contains("sk-secret-12345"),
525 "Debug output must not contain the real secret"
526 );
527 assert!(
528 !debug_output.contains("Bearer sk-secret"),
529 "Debug output must not contain the formatted secret"
530 );
531 assert!(debug_output.contains("Authorization"));
533 }
534
535 #[test]
536 fn test_load_no_credential_routes() {
537 let tls = test_tls_connector();
538 let routes = vec![RouteConfig {
539 prefix: "/test".to_string(),
540 upstream: "https://example.com".to_string(),
541 credential_key: None,
542 inject_mode: InjectMode::Header,
543 inject_header: "Authorization".to_string(),
544 credential_format: "Bearer {}".to_string(),
545 path_pattern: None,
546 path_replacement: None,
547 query_param_name: None,
548 proxy: None,
549 env_var: None,
550 endpoint_rules: vec![],
551 tls_ca: None,
552 tls_client_cert: None,
553 tls_client_key: None,
554 oauth2: None,
555 }];
556 let store = CredentialStore::load(&routes, &tls);
557 assert!(store.is_ok());
558 let store = store.unwrap_or_else(|_| CredentialStore::empty());
559 assert!(store.is_empty());
560 }
561
562 #[test]
563 fn test_get_oauth2_returns_none_for_non_oauth2_routes() {
564 let store = CredentialStore::empty();
565 assert!(store.get_oauth2("openai").is_none());
566 assert!(store.get_oauth2("my-api").is_none());
567 }
568
569 #[test]
570 fn test_is_empty_false_with_only_oauth2_routes() {
571 use std::time::Duration;
575
576 let cache = make_test_token_cache("test-token", Duration::from_secs(3600));
577 let mut oauth2_routes = HashMap::new();
578 oauth2_routes.insert(
579 "my-api".to_string(),
580 OAuth2Route {
581 cache,
582 upstream: "https://api.example.com".to_string(),
583 },
584 );
585
586 let store = CredentialStore {
587 credentials: HashMap::new(),
588 oauth2_routes,
589 };
590
591 assert!(
592 !store.is_empty(),
593 "store with OAuth2 routes should not be empty"
594 );
595 assert_eq!(store.len(), 1);
596 assert!(store.get_oauth2("my-api").is_some());
597 assert!(store.get("my-api").is_none());
598 }
599
600 #[test]
601 fn test_loaded_prefixes_includes_oauth2() {
602 use std::time::Duration;
603
604 let cache = make_test_token_cache("test-token", Duration::from_secs(3600));
605 let mut oauth2_routes = HashMap::new();
606 oauth2_routes.insert(
607 "my-api".to_string(),
608 OAuth2Route {
609 cache,
610 upstream: "https://api.example.com".to_string(),
611 },
612 );
613
614 let store = CredentialStore {
615 credentials: HashMap::new(),
616 oauth2_routes,
617 };
618
619 let prefixes = store.loaded_prefixes();
620 assert!(prefixes.contains("my-api"));
621 }
622
623 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
624 async fn test_load_oauth2_unreachable_endpoint_skips_route() {
625 use crate::config::OAuth2Config;
626
627 let _lock = ENV_LOCK.lock().unwrap();
628 let _env = EnvVarGuard::set_all(&[
629 ("TEST_OAUTH2_CLIENT_ID", "test-client"),
630 ("TEST_OAUTH2_CLIENT_SECRET", "test-secret"),
631 ]);
632 let tls = test_tls_connector();
633 let routes = vec![RouteConfig {
634 prefix: "my-api".to_string(),
635 upstream: "https://api.example.com".to_string(),
636 credential_key: None,
637 inject_mode: InjectMode::Header,
638 inject_header: "Authorization".to_string(),
639 credential_format: "Bearer {}".to_string(),
640 path_pattern: None,
641 path_replacement: None,
642 query_param_name: None,
643 proxy: None,
644 env_var: Some("MY_API_KEY".to_string()),
645 endpoint_rules: vec![],
646 tls_ca: None,
647 tls_client_cert: None,
648 tls_client_key: None,
649 oauth2: Some(OAuth2Config {
650 token_url: "https://127.0.0.1:1/oauth/token".to_string(),
652 client_id: "env://TEST_OAUTH2_CLIENT_ID".to_string(),
654 client_secret: "env://TEST_OAUTH2_CLIENT_SECRET".to_string(),
655 scope: String::new(),
656 }),
657 }];
658
659 let store = CredentialStore::load(&routes, &tls);
660
661 assert!(
663 store.is_ok(),
664 "load should not fail on unreachable OAuth2 endpoint"
665 );
666 let store = store.unwrap();
667
668 assert!(
670 store.is_empty(),
671 "unreachable OAuth2 endpoint should result in skipped route"
672 );
673 assert!(store.get_oauth2("my-api").is_none());
674 }
675
676 fn make_test_token_cache(token: &str, ttl: std::time::Duration) -> TokenCache {
678 use crate::oauth2::OAuth2ExchangeConfig;
679
680 let config = OAuth2ExchangeConfig {
681 token_url: "https://127.0.0.1:1/oauth/token".to_string(),
682 client_id: Zeroizing::new("test-client".to_string()),
683 client_secret: Zeroizing::new("test-secret".to_string()),
684 scope: String::new(),
685 };
686
687 TokenCache::new_from_parts(config, test_tls_connector(), token, ttl)
688 }
689}