1use crate::config::{CompiledEndpointRules, InjectMode, RouteConfig};
9use crate::error::{ProxyError, Result};
10use base64::Engine;
11use std::collections::HashMap;
12use tracing::debug;
13use zeroize::Zeroizing;
14
15pub struct LoadedCredential {
17 pub inject_mode: InjectMode,
19 pub upstream: String,
21 pub raw_credential: Zeroizing<String>,
23
24 pub header_name: String,
27 pub header_value: Zeroizing<String>,
29
30 pub path_pattern: Option<String>,
33 pub path_replacement: Option<String>,
35
36 pub query_param_name: Option<String>,
39
40 pub endpoint_rules: CompiledEndpointRules,
44}
45
46impl std::fmt::Debug for LoadedCredential {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 f.debug_struct("LoadedCredential")
51 .field("inject_mode", &self.inject_mode)
52 .field("upstream", &self.upstream)
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 .field("endpoint_rules", &self.endpoint_rules)
60 .finish()
61 }
62}
63
64#[derive(Debug)]
66pub struct CredentialStore {
67 credentials: HashMap<String, LoadedCredential>,
69}
70
71impl CredentialStore {
72 pub fn load(routes: &[RouteConfig]) -> Result<Self> {
82 let mut credentials = HashMap::new();
83
84 for route in routes {
85 if let Some(ref key) = route.credential_key {
86 debug!(
87 "Loading credential for route prefix: {} (mode: {:?})",
88 route.prefix, route.inject_mode
89 );
90
91 let secret = match nono::keystore::load_secret_by_ref(KEYRING_SERVICE, key) {
92 Ok(s) => s,
93 Err(nono::NonoError::SecretNotFound(msg)) => {
94 debug!(
95 "Credential '{}' not available, skipping route: {}",
96 route.prefix, msg
97 );
98 continue;
99 }
100 Err(e) => return Err(ProxyError::Credential(e.to_string())),
101 };
102
103 let header_value = match route.inject_mode {
105 InjectMode::Header => {
106 Zeroizing::new(route.credential_format.replace("{}", &secret))
107 }
108 InjectMode::BasicAuth => {
109 let encoded =
111 base64::engine::general_purpose::STANDARD.encode(secret.as_bytes());
112 Zeroizing::new(format!("Basic {}", encoded))
113 }
114 InjectMode::UrlPath | InjectMode::QueryParam => Zeroizing::new(String::new()),
116 };
117
118 credentials.insert(
119 route.prefix.clone(),
120 LoadedCredential {
121 inject_mode: route.inject_mode.clone(),
122 upstream: route.upstream.clone(),
123 raw_credential: secret,
124 header_name: route.inject_header.clone(),
125 header_value,
126 path_pattern: route.path_pattern.clone(),
127 path_replacement: route.path_replacement.clone(),
128 query_param_name: route.query_param_name.clone(),
129 endpoint_rules: CompiledEndpointRules::compile(&route.endpoint_rules)
130 .map_err(|e| {
131 ProxyError::Credential(format!("route '{}': {}", route.prefix, e))
132 })?,
133 },
134 );
135 }
136 }
137
138 Ok(Self { credentials })
139 }
140
141 #[must_use]
143 pub fn empty() -> Self {
144 Self {
145 credentials: HashMap::new(),
146 }
147 }
148
149 #[must_use]
151 pub fn get(&self, prefix: &str) -> Option<&LoadedCredential> {
152 self.credentials.get(prefix)
153 }
154
155 #[must_use]
157 pub fn is_empty(&self) -> bool {
158 self.credentials.is_empty()
159 }
160
161 #[must_use]
163 pub fn len(&self) -> usize {
164 self.credentials.len()
165 }
166
167 #[must_use]
169 pub fn loaded_prefixes(&self) -> std::collections::HashSet<String> {
170 self.credentials.keys().cloned().collect()
171 }
172}
173
174const KEYRING_SERVICE: &str = nono::keystore::DEFAULT_SERVICE;
177
178#[cfg(test)]
179#[allow(clippy::unwrap_used)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn test_empty_credential_store() {
185 let store = CredentialStore::empty();
186 assert!(store.is_empty());
187 assert_eq!(store.len(), 0);
188 assert!(store.get("/openai").is_none());
189 }
190
191 #[test]
192 fn test_loaded_credential_debug_redacts_secrets() {
193 let cred = LoadedCredential {
197 inject_mode: InjectMode::Header,
198 upstream: "https://api.openai.com".to_string(),
199 raw_credential: Zeroizing::new("sk-secret-12345".to_string()),
200 header_name: "Authorization".to_string(),
201 header_value: Zeroizing::new("Bearer sk-secret-12345".to_string()),
202 path_pattern: None,
203 path_replacement: None,
204 query_param_name: None,
205 endpoint_rules: CompiledEndpointRules::compile(&[]).unwrap(),
206 };
207
208 let debug_output = format!("{:?}", cred);
209
210 assert!(
212 debug_output.contains("[REDACTED]"),
213 "Debug output should contain [REDACTED], got: {}",
214 debug_output
215 );
216 assert!(
218 !debug_output.contains("sk-secret-12345"),
219 "Debug output must not contain the real secret"
220 );
221 assert!(
222 !debug_output.contains("Bearer sk-secret"),
223 "Debug output must not contain the formatted secret"
224 );
225 assert!(debug_output.contains("api.openai.com"));
227 assert!(debug_output.contains("Authorization"));
228 }
229
230 #[test]
231 fn test_load_no_credential_routes() {
232 let routes = vec![RouteConfig {
233 prefix: "/test".to_string(),
234 upstream: "https://example.com".to_string(),
235 credential_key: None,
236 inject_mode: InjectMode::Header,
237 inject_header: "Authorization".to_string(),
238 credential_format: "Bearer {}".to_string(),
239 path_pattern: None,
240 path_replacement: None,
241 query_param_name: None,
242 env_var: None,
243 endpoint_rules: vec![],
244 }];
245 let store = CredentialStore::load(&routes);
246 assert!(store.is_ok());
247 let store = store.unwrap_or_else(|_| CredentialStore::empty());
248 assert!(store.is_empty());
249 }
250}