1use crate::config::{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
41impl std::fmt::Debug for LoadedCredential {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 f.debug_struct("LoadedCredential")
46 .field("inject_mode", &self.inject_mode)
47 .field("upstream", &self.upstream)
48 .field("raw_credential", &"[REDACTED]")
49 .field("header_name", &self.header_name)
50 .field("header_value", &"[REDACTED]")
51 .field("path_pattern", &self.path_pattern)
52 .field("path_replacement", &self.path_replacement)
53 .field("query_param_name", &self.query_param_name)
54 .finish()
55 }
56}
57
58#[derive(Debug)]
60pub struct CredentialStore {
61 credentials: HashMap<String, LoadedCredential>,
63}
64
65impl CredentialStore {
66 pub fn load(routes: &[RouteConfig]) -> Result<Self> {
76 let mut credentials = HashMap::new();
77
78 for route in routes {
79 if let Some(ref key) = route.credential_key {
80 debug!(
81 "Loading credential for route prefix: {} (mode: {:?})",
82 route.prefix, route.inject_mode
83 );
84
85 let secret = match nono::keystore::load_secret_by_ref(KEYRING_SERVICE, key) {
86 Ok(s) => s,
87 Err(nono::NonoError::SecretNotFound(msg)) => {
88 debug!(
89 "Credential '{}' not available, skipping route: {}",
90 route.prefix, msg
91 );
92 continue;
93 }
94 Err(e) => return Err(ProxyError::Credential(e.to_string())),
95 };
96
97 let header_value = match route.inject_mode {
99 InjectMode::Header => {
100 Zeroizing::new(route.credential_format.replace("{}", &secret))
101 }
102 InjectMode::BasicAuth => {
103 let encoded =
105 base64::engine::general_purpose::STANDARD.encode(secret.as_bytes());
106 Zeroizing::new(format!("Basic {}", encoded))
107 }
108 InjectMode::UrlPath | InjectMode::QueryParam => Zeroizing::new(String::new()),
110 };
111
112 credentials.insert(
113 route.prefix.clone(),
114 LoadedCredential {
115 inject_mode: route.inject_mode.clone(),
116 upstream: route.upstream.clone(),
117 raw_credential: secret,
118 header_name: route.inject_header.clone(),
119 header_value,
120 path_pattern: route.path_pattern.clone(),
121 path_replacement: route.path_replacement.clone(),
122 query_param_name: route.query_param_name.clone(),
123 },
124 );
125 }
126 }
127
128 Ok(Self { credentials })
129 }
130
131 #[must_use]
133 pub fn empty() -> Self {
134 Self {
135 credentials: HashMap::new(),
136 }
137 }
138
139 #[must_use]
141 pub fn get(&self, prefix: &str) -> Option<&LoadedCredential> {
142 self.credentials.get(prefix)
143 }
144
145 #[must_use]
147 pub fn is_empty(&self) -> bool {
148 self.credentials.is_empty()
149 }
150
151 #[must_use]
153 pub fn len(&self) -> usize {
154 self.credentials.len()
155 }
156
157 #[must_use]
159 pub fn loaded_prefixes(&self) -> std::collections::HashSet<String> {
160 self.credentials.keys().cloned().collect()
161 }
162}
163
164const KEYRING_SERVICE: &str = nono::keystore::DEFAULT_SERVICE;
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn test_empty_credential_store() {
174 let store = CredentialStore::empty();
175 assert!(store.is_empty());
176 assert_eq!(store.len(), 0);
177 assert!(store.get("/openai").is_none());
178 }
179
180 #[test]
181 fn test_loaded_credential_debug_redacts_secrets() {
182 let cred = LoadedCredential {
186 inject_mode: InjectMode::Header,
187 upstream: "https://api.openai.com".to_string(),
188 raw_credential: Zeroizing::new("sk-secret-12345".to_string()),
189 header_name: "Authorization".to_string(),
190 header_value: Zeroizing::new("Bearer sk-secret-12345".to_string()),
191 path_pattern: None,
192 path_replacement: None,
193 query_param_name: None,
194 };
195
196 let debug_output = format!("{:?}", cred);
197
198 assert!(
200 debug_output.contains("[REDACTED]"),
201 "Debug output should contain [REDACTED], got: {}",
202 debug_output
203 );
204 assert!(
206 !debug_output.contains("sk-secret-12345"),
207 "Debug output must not contain the real secret"
208 );
209 assert!(
210 !debug_output.contains("Bearer sk-secret"),
211 "Debug output must not contain the formatted secret"
212 );
213 assert!(debug_output.contains("api.openai.com"));
215 assert!(debug_output.contains("Authorization"));
216 }
217
218 #[test]
219 fn test_load_no_credential_routes() {
220 let routes = vec![RouteConfig {
221 prefix: "/test".to_string(),
222 upstream: "https://example.com".to_string(),
223 credential_key: None,
224 inject_mode: InjectMode::Header,
225 inject_header: "Authorization".to_string(),
226 credential_format: "Bearer {}".to_string(),
227 path_pattern: None,
228 path_replacement: None,
229 query_param_name: None,
230 env_var: None,
231 }];
232 let store = CredentialStore::load(&routes);
233 assert!(store.is_ok());
234 let store = store.unwrap_or_else(|_| CredentialStore::empty());
235 assert!(store.is_empty());
236 }
237}