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 unsafe { std::env::set_var(key, value) };
412 }
413
414 Self { original }
415 }
416 }
417
418 #[allow(clippy::disallowed_methods)]
419 impl Drop for EnvVarGuard {
420 fn drop(&mut self) {
421 for (key, value) in self.original.iter().rev() {
422 match value {
424 Some(value) => unsafe { std::env::set_var(key, value) },
425 None => unsafe { std::env::remove_var(key) },
426 }
427 }
428 }
429 }
430
431 fn test_tls_connector() -> TlsConnector {
433 let mut root_store = rustls::RootCertStore::empty();
434 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
435 let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(
436 rustls::crypto::ring::default_provider(),
437 ))
438 .with_safe_default_protocol_versions()
439 .unwrap()
440 .with_root_certificates(root_store)
441 .with_no_client_auth();
442 TlsConnector::from(Arc::new(tls_config))
443 }
444
445 #[test]
446 fn test_empty_credential_store() {
447 let store = CredentialStore::empty();
448 assert!(store.is_empty());
449 assert_eq!(store.len(), 0);
450 assert!(store.get("openai").is_none());
451 assert!(store.get("/openai").is_none());
452 assert!(store.get_oauth2("/openai").is_none());
453 }
454
455 #[test]
460 fn test_miss_hint_env_uri_with_keyring_fallback_message() {
461 let hint = build_credential_miss_hint("env://NONONO_TEST_MISSING_VAR");
465 assert!(
466 hint.contains("NONONO_TEST_MISSING_VAR"),
467 "hint should name the missing variable, got: {}",
468 hint
469 );
470 }
471
472 #[test]
475 fn test_miss_hint_bare_key_with_env_var_set() {
476 let _lock = ENV_LOCK.lock().expect("env mutex poisoned");
477 let _guard = EnvVarGuard::set_all(&[("NONONO_TEST_BARE_KEY", "secret-value")]);
478
479 let hint = build_credential_miss_hint("NONONO_TEST_BARE_KEY");
480 assert!(
481 hint.contains("env://NONONO_TEST_BARE_KEY"),
482 "hint should suggest env:// URI, got: {}",
483 hint
484 );
485 }
486
487 #[test]
489 fn test_miss_hint_op_uri_returns_empty() {
490 let hint = build_credential_miss_hint("op://Vault/Item/field");
491 assert!(
492 hint.is_empty(),
493 "URI-managed sources should not get cross-probe hints, got: {}",
494 hint
495 );
496 }
497
498 #[test]
499 fn test_loaded_credential_debug_redacts_secrets() {
500 let cred = LoadedCredential {
504 inject_mode: InjectMode::Header,
505 proxy_inject_mode: InjectMode::Header,
506 raw_credential: Zeroizing::new("sk-secret-12345".to_string()),
507 header_name: "Authorization".to_string(),
508 proxy_header_name: "Authorization".to_string(),
509 header_value: Zeroizing::new("Bearer sk-secret-12345".to_string()),
510 path_pattern: None,
511 proxy_path_pattern: None,
512 path_replacement: None,
513 query_param_name: None,
514 proxy_query_param_name: None,
515 };
516
517 let debug_output = format!("{:?}", cred);
518
519 assert!(
521 debug_output.contains("[REDACTED]"),
522 "Debug output should contain [REDACTED], got: {}",
523 debug_output
524 );
525 assert!(
527 !debug_output.contains("sk-secret-12345"),
528 "Debug output must not contain the real secret"
529 );
530 assert!(
531 !debug_output.contains("Bearer sk-secret"),
532 "Debug output must not contain the formatted secret"
533 );
534 assert!(debug_output.contains("Authorization"));
536 }
537
538 #[test]
539 fn test_load_no_credential_routes() {
540 let tls = test_tls_connector();
541 let routes = vec![RouteConfig {
542 prefix: "/test".to_string(),
543 upstream: "https://example.com".to_string(),
544 credential_key: None,
545 inject_mode: InjectMode::Header,
546 inject_header: "Authorization".to_string(),
547 credential_format: "Bearer {}".to_string(),
548 path_pattern: None,
549 path_replacement: None,
550 query_param_name: None,
551 proxy: None,
552 env_var: None,
553 endpoint_rules: vec![],
554 tls_ca: None,
555 tls_client_cert: None,
556 tls_client_key: None,
557 oauth2: None,
558 }];
559 let store = CredentialStore::load(&routes, &tls);
560 assert!(store.is_ok());
561 let store = store.unwrap_or_else(|_| CredentialStore::empty());
562 assert!(store.is_empty());
563 }
564
565 #[test]
566 fn test_get_oauth2_returns_none_for_non_oauth2_routes() {
567 let store = CredentialStore::empty();
568 assert!(store.get_oauth2("openai").is_none());
569 assert!(store.get_oauth2("my-api").is_none());
570 }
571
572 #[test]
573 fn test_is_empty_false_with_only_oauth2_routes() {
574 use std::time::Duration;
578
579 let cache = make_test_token_cache("test-token", Duration::from_secs(3600));
580 let mut oauth2_routes = HashMap::new();
581 oauth2_routes.insert(
582 "my-api".to_string(),
583 OAuth2Route {
584 cache,
585 upstream: "https://api.example.com".to_string(),
586 },
587 );
588
589 let store = CredentialStore {
590 credentials: HashMap::new(),
591 oauth2_routes,
592 };
593
594 assert!(
595 !store.is_empty(),
596 "store with OAuth2 routes should not be empty"
597 );
598 assert_eq!(store.len(), 1);
599 assert!(store.get_oauth2("my-api").is_some());
600 assert!(store.get("my-api").is_none());
601 }
602
603 #[test]
604 fn test_loaded_prefixes_includes_oauth2() {
605 use std::time::Duration;
606
607 let cache = make_test_token_cache("test-token", Duration::from_secs(3600));
608 let mut oauth2_routes = HashMap::new();
609 oauth2_routes.insert(
610 "my-api".to_string(),
611 OAuth2Route {
612 cache,
613 upstream: "https://api.example.com".to_string(),
614 },
615 );
616
617 let store = CredentialStore {
618 credentials: HashMap::new(),
619 oauth2_routes,
620 };
621
622 let prefixes = store.loaded_prefixes();
623 assert!(prefixes.contains("my-api"));
624 }
625
626 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
627 async fn test_load_oauth2_unreachable_endpoint_skips_route() {
628 use crate::config::OAuth2Config;
629
630 let _lock = ENV_LOCK.lock().unwrap();
631 let _env = EnvVarGuard::set_all(&[
632 ("TEST_OAUTH2_CLIENT_ID", "test-client"),
633 ("TEST_OAUTH2_CLIENT_SECRET", "test-secret"),
634 ]);
635 let tls = test_tls_connector();
636 let routes = vec![RouteConfig {
637 prefix: "my-api".to_string(),
638 upstream: "https://api.example.com".to_string(),
639 credential_key: None,
640 inject_mode: InjectMode::Header,
641 inject_header: "Authorization".to_string(),
642 credential_format: "Bearer {}".to_string(),
643 path_pattern: None,
644 path_replacement: None,
645 query_param_name: None,
646 proxy: None,
647 env_var: Some("MY_API_KEY".to_string()),
648 endpoint_rules: vec![],
649 tls_ca: None,
650 tls_client_cert: None,
651 tls_client_key: None,
652 oauth2: Some(OAuth2Config {
653 token_url: "https://127.0.0.1:1/oauth/token".to_string(),
655 client_id: "env://TEST_OAUTH2_CLIENT_ID".to_string(),
657 client_secret: "env://TEST_OAUTH2_CLIENT_SECRET".to_string(),
658 scope: String::new(),
659 }),
660 }];
661
662 let store = CredentialStore::load(&routes, &tls);
663
664 assert!(
666 store.is_ok(),
667 "load should not fail on unreachable OAuth2 endpoint"
668 );
669 let store = store.unwrap();
670
671 assert!(
673 store.is_empty(),
674 "unreachable OAuth2 endpoint should result in skipped route"
675 );
676 assert!(store.get_oauth2("my-api").is_none());
677 }
678
679 fn make_test_token_cache(token: &str, ttl: std::time::Duration) -> TokenCache {
681 use crate::oauth2::OAuth2ExchangeConfig;
682
683 let config = OAuth2ExchangeConfig {
684 token_url: "https://127.0.0.1:1/oauth/token".to_string(),
685 client_id: Zeroizing::new("test-client".to_string()),
686 client_secret: Zeroizing::new("test-secret".to_string()),
687 scope: String::new(),
688 };
689
690 TokenCache::new_from_parts(config, test_tls_connector(), token, ttl)
691 }
692}