1use crate::config::{InjectMode, RouteConfig};
13use crate::error::{ProxyError, Result};
14use base64::Engine;
15use std::collections::HashMap;
16use tracing::{debug, warn};
17use zeroize::Zeroizing;
18
19pub struct LoadedCredential {
25 pub inject_mode: InjectMode,
27 pub raw_credential: Zeroizing<String>,
29
30 pub header_name: String,
33 pub header_value: Zeroizing<String>,
35
36 pub path_pattern: Option<String>,
39 pub path_replacement: Option<String>,
41
42 pub query_param_name: Option<String>,
45}
46
47impl std::fmt::Debug for LoadedCredential {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 f.debug_struct("LoadedCredential")
52 .field("inject_mode", &self.inject_mode)
53 .field("raw_credential", &"[REDACTED]")
54 .field("header_name", &self.header_name)
55 .field("header_value", &"[REDACTED]")
56 .field("path_pattern", &self.path_pattern)
57 .field("path_replacement", &self.path_replacement)
58 .field("query_param_name", &self.query_param_name)
59 .finish()
60 }
61}
62
63#[derive(Debug)]
65pub struct CredentialStore {
66 credentials: HashMap<String, LoadedCredential>,
68}
69
70impl CredentialStore {
71 pub fn load(routes: &[RouteConfig]) -> Result<Self> {
81 let mut credentials = HashMap::new();
82
83 for route in routes {
84 let normalized_prefix = route.prefix.trim_matches('/').to_string();
88
89 if let Some(ref key) = route.credential_key {
90 debug!(
91 "Loading credential for route prefix: {} (mode: {:?})",
92 normalized_prefix, route.inject_mode
93 );
94
95 let secret = match nono::keystore::load_secret_by_ref(KEYRING_SERVICE, key) {
96 Ok(s) => s,
97 Err(nono::NonoError::SecretNotFound(_)) => {
98 let hint = if !key.contains("://") && cfg!(target_os = "macos") {
99 format!(
100 " To add it to the macOS keychain: security add-generic-password -s \"nono\" -a \"{}\" -w",
101 key
102 )
103 } else {
104 String::new()
105 };
106 warn!(
107 "Credential '{}' not found for route '{}' — requests will proceed without credential injection.{}",
108 key, normalized_prefix, hint
109 );
110 continue;
111 }
112 Err(e) => return Err(ProxyError::Credential(e.to_string())),
113 };
114
115 let effective_format = if route.inject_header != "Authorization"
121 && route.credential_format == "Bearer {}"
122 {
123 "{}".to_string()
124 } else {
125 route.credential_format.clone()
126 };
127
128 let header_value = match route.inject_mode {
129 InjectMode::Header => Zeroizing::new(effective_format.replace("{}", &secret)),
130 InjectMode::BasicAuth => {
131 let encoded =
133 base64::engine::general_purpose::STANDARD.encode(secret.as_bytes());
134 Zeroizing::new(format!("Basic {}", encoded))
135 }
136 InjectMode::UrlPath | InjectMode::QueryParam => Zeroizing::new(String::new()),
138 };
139
140 credentials.insert(
141 normalized_prefix.clone(),
142 LoadedCredential {
143 inject_mode: route.inject_mode.clone(),
144 raw_credential: secret,
145 header_name: route.inject_header.clone(),
146 header_value,
147 path_pattern: route.path_pattern.clone(),
148 path_replacement: route.path_replacement.clone(),
149 query_param_name: route.query_param_name.clone(),
150 },
151 );
152 }
153 }
154
155 Ok(Self { credentials })
156 }
157
158 #[must_use]
160 pub fn empty() -> Self {
161 Self {
162 credentials: HashMap::new(),
163 }
164 }
165
166 #[must_use]
168 pub fn get(&self, prefix: &str) -> Option<&LoadedCredential> {
169 self.credentials.get(prefix)
170 }
171
172 #[must_use]
174 pub fn is_empty(&self) -> bool {
175 self.credentials.is_empty()
176 }
177
178 #[must_use]
180 pub fn len(&self) -> usize {
181 self.credentials.len()
182 }
183
184 #[must_use]
186 pub fn loaded_prefixes(&self) -> std::collections::HashSet<String> {
187 self.credentials.keys().cloned().collect()
188 }
189}
190
191const KEYRING_SERVICE: &str = nono::keystore::DEFAULT_SERVICE;
194
195#[cfg(test)]
196#[allow(clippy::unwrap_used)]
197mod tests {
198 use super::*;
199
200 #[test]
201 fn test_empty_credential_store() {
202 let store = CredentialStore::empty();
203 assert!(store.is_empty());
204 assert_eq!(store.len(), 0);
205 assert!(store.get("openai").is_none());
206 }
207
208 #[test]
209 fn test_loaded_credential_debug_redacts_secrets() {
210 let cred = LoadedCredential {
214 inject_mode: InjectMode::Header,
215 raw_credential: Zeroizing::new("sk-secret-12345".to_string()),
216 header_name: "Authorization".to_string(),
217 header_value: Zeroizing::new("Bearer sk-secret-12345".to_string()),
218 path_pattern: None,
219 path_replacement: None,
220 query_param_name: None,
221 };
222
223 let debug_output = format!("{:?}", cred);
224
225 assert!(
227 debug_output.contains("[REDACTED]"),
228 "Debug output should contain [REDACTED], got: {}",
229 debug_output
230 );
231 assert!(
233 !debug_output.contains("sk-secret-12345"),
234 "Debug output must not contain the real secret"
235 );
236 assert!(
237 !debug_output.contains("Bearer sk-secret"),
238 "Debug output must not contain the formatted secret"
239 );
240 assert!(debug_output.contains("Authorization"));
242 }
243
244 #[test]
245 fn test_load_no_credential_routes() {
246 let routes = vec![RouteConfig {
247 prefix: "/test".to_string(),
248 upstream: "https://example.com".to_string(),
249 credential_key: None,
250 inject_mode: InjectMode::Header,
251 inject_header: "Authorization".to_string(),
252 credential_format: "Bearer {}".to_string(),
253 path_pattern: None,
254 path_replacement: None,
255 query_param_name: None,
256 env_var: None,
257 endpoint_rules: vec![],
258 tls_ca: None,
259 tls_client_cert: None,
260 tls_client_key: None,
261 }];
262 let store = CredentialStore::load(&routes);
263 assert!(store.is_ok());
264 let store = store.unwrap_or_else(|_| CredentialStore::empty());
265 assert!(store.is_empty());
266 }
267}