1use crate::config::{InjectMode, RouteConfig};
13use crate::error::{ProxyError, Result};
14use base64::Engine;
15use std::collections::HashMap;
16use tracing::debug;
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(msg)) => {
98 debug!(
99 "Credential '{}' not available, skipping route: {}",
100 normalized_prefix, msg
101 );
102 continue;
103 }
104 Err(e) => return Err(ProxyError::Credential(e.to_string())),
105 };
106
107 let effective_format = if route.inject_header != "Authorization"
113 && route.credential_format == "Bearer {}"
114 {
115 "{}".to_string()
116 } else {
117 route.credential_format.clone()
118 };
119
120 let header_value = match route.inject_mode {
121 InjectMode::Header => Zeroizing::new(effective_format.replace("{}", &secret)),
122 InjectMode::BasicAuth => {
123 let encoded =
125 base64::engine::general_purpose::STANDARD.encode(secret.as_bytes());
126 Zeroizing::new(format!("Basic {}", encoded))
127 }
128 InjectMode::UrlPath | InjectMode::QueryParam => Zeroizing::new(String::new()),
130 };
131
132 credentials.insert(
133 normalized_prefix.clone(),
134 LoadedCredential {
135 inject_mode: route.inject_mode.clone(),
136 raw_credential: secret,
137 header_name: route.inject_header.clone(),
138 header_value,
139 path_pattern: route.path_pattern.clone(),
140 path_replacement: route.path_replacement.clone(),
141 query_param_name: route.query_param_name.clone(),
142 },
143 );
144 }
145 }
146
147 Ok(Self { credentials })
148 }
149
150 #[must_use]
152 pub fn empty() -> Self {
153 Self {
154 credentials: HashMap::new(),
155 }
156 }
157
158 #[must_use]
160 pub fn get(&self, prefix: &str) -> Option<&LoadedCredential> {
161 self.credentials.get(prefix)
162 }
163
164 #[must_use]
166 pub fn is_empty(&self) -> bool {
167 self.credentials.is_empty()
168 }
169
170 #[must_use]
172 pub fn len(&self) -> usize {
173 self.credentials.len()
174 }
175
176 #[must_use]
178 pub fn loaded_prefixes(&self) -> std::collections::HashSet<String> {
179 self.credentials.keys().cloned().collect()
180 }
181}
182
183const KEYRING_SERVICE: &str = nono::keystore::DEFAULT_SERVICE;
186
187#[cfg(test)]
188#[allow(clippy::unwrap_used)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn test_empty_credential_store() {
194 let store = CredentialStore::empty();
195 assert!(store.is_empty());
196 assert_eq!(store.len(), 0);
197 assert!(store.get("openai").is_none());
198 }
199
200 #[test]
201 fn test_loaded_credential_debug_redacts_secrets() {
202 let cred = LoadedCredential {
206 inject_mode: InjectMode::Header,
207 raw_credential: Zeroizing::new("sk-secret-12345".to_string()),
208 header_name: "Authorization".to_string(),
209 header_value: Zeroizing::new("Bearer sk-secret-12345".to_string()),
210 path_pattern: None,
211 path_replacement: None,
212 query_param_name: None,
213 };
214
215 let debug_output = format!("{:?}", cred);
216
217 assert!(
219 debug_output.contains("[REDACTED]"),
220 "Debug output should contain [REDACTED], got: {}",
221 debug_output
222 );
223 assert!(
225 !debug_output.contains("sk-secret-12345"),
226 "Debug output must not contain the real secret"
227 );
228 assert!(
229 !debug_output.contains("Bearer sk-secret"),
230 "Debug output must not contain the formatted secret"
231 );
232 assert!(debug_output.contains("Authorization"));
234 }
235
236 #[test]
237 fn test_load_no_credential_routes() {
238 let routes = vec![RouteConfig {
239 prefix: "/test".to_string(),
240 upstream: "https://example.com".to_string(),
241 credential_key: None,
242 inject_mode: InjectMode::Header,
243 inject_header: "Authorization".to_string(),
244 credential_format: "Bearer {}".to_string(),
245 path_pattern: None,
246 path_replacement: None,
247 query_param_name: None,
248 env_var: None,
249 endpoint_rules: vec![],
250 tls_ca: None,
251 }];
252 let store = CredentialStore::load(&routes);
253 assert!(store.is_ok());
254 let store = store.unwrap_or_else(|_| CredentialStore::empty());
255 assert!(store.is_empty());
256 }
257}