1use async_trait::async_trait;
2use camel_api::CamelError;
3use camel_api::security_policy::Principal;
4use std::fmt;
5use tracing::warn;
6use zeroize::Zeroizing;
7
8pub struct NativeCredential {
9 pub secret: NativeCredentialSecret,
10 pub principal: Principal,
11}
12
13#[derive(Clone)]
14pub enum NativeCredentialSecret {
15 Env { name: String },
16 Plaintext { value: Zeroizing<String> },
17}
18
19#[derive(Clone)]
20struct ResolvedCredential {
21 secret_value: Zeroizing<String>,
22 principal: Principal,
23}
24
25pub struct NativeCredentialStore {
26 credentials: Vec<ResolvedCredential>,
27}
28
29impl fmt::Debug for NativeCredentialSecret {
30 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31 match self {
32 NativeCredentialSecret::Env { name } => {
33 write!(f, "Env {{ name: \"{name}\" }}") }
35 NativeCredentialSecret::Plaintext { .. } => {
36 write!(f, "Plaintext {{ value: \"[REDACTED]\" }}") }
38 }
39 }
40}
41
42impl fmt::Debug for NativeCredential {
43 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44 f.debug_struct("NativeCredential")
45 .field("secret", &self.secret)
46 .field("principal", &self.principal.subject)
47 .finish()
48 }
49}
50
51impl fmt::Debug for ResolvedCredential {
52 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53 f.debug_struct("ResolvedCredential")
54 .field("secret_value", &"[REDACTED]")
55 .field("principal", &self.principal.subject)
56 .finish()
57 }
58}
59
60impl fmt::Debug for NativeCredentialStore {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 f.debug_struct("NativeCredentialStore")
63 .field("credential_count", &self.credentials.len())
64 .finish()
65 }
66}
67
68impl NativeCredentialStore {
69 pub fn try_new(credentials: Vec<NativeCredential>) -> Result<Self, CamelError> {
70 let mut resolved = Vec::with_capacity(credentials.len());
71 for c in credentials {
72 let secret_value = match &c.secret {
73 NativeCredentialSecret::Env { name } => {
74 let val = std::env::var(name).map_err(|_| {
75 CamelError::Config(format!("native auth env var not set: {name}"))
76 })?;
77 if val.is_empty() {
78 return Err(CamelError::Config(format!(
79 "native auth env var is empty: {name}"
80 )));
81 }
82 Zeroizing::new(val)
83 }
84 NativeCredentialSecret::Plaintext { value } => {
85 if value.is_empty() {
86 return Err(CamelError::Config(
87 "native auth plaintext secret is empty".into(),
88 ));
89 }
90 warn!("native credential uses plaintext secret — use env vars in production");
91 value.clone()
92 }
93 };
94 resolved.push(ResolvedCredential {
95 secret_value,
96 principal: c.principal,
97 });
98 }
99 Ok(Self {
100 credentials: resolved,
101 })
102 }
103
104 pub fn lookup(&self, presented: &str) -> Option<&Principal> {
105 if presented.is_empty() {
106 return None;
107 }
108 for c in &self.credentials {
109 let a = c.secret_value.as_bytes();
110 let b = presented.as_bytes();
111 let mut acc: u8 = if a.len() != b.len() { 1 } else { 0 };
112 let max_len = a.len().max(b.len());
113 for i in 0..max_len {
114 let x = if i < a.len() { a[i] } else { 0 };
115 let y = if i < b.len() { b[i] } else { 0 };
116 acc |= x ^ y;
117 }
118 if acc == 0 {
119 return Some(&c.principal);
120 }
121 }
122 None
123 }
124}
125
126pub struct StaticTokenAuthenticator {
127 store: NativeCredentialStore,
128}
129
130impl fmt::Debug for StaticTokenAuthenticator {
131 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132 f.debug_struct("StaticTokenAuthenticator")
133 .field("store", &"[REDACTED]")
134 .finish()
135 }
136}
137
138impl StaticTokenAuthenticator {
139 pub fn new(store: NativeCredentialStore) -> Self {
140 Self { store }
141 }
142}
143
144#[async_trait]
145impl crate::TokenAuthenticator for StaticTokenAuthenticator {
146 async fn authenticate_bearer(&self, token: &str) -> Result<Principal, CamelError> {
147 self.store
148 .lookup(token)
149 .cloned()
150 .ok_or_else(|| CamelError::Unauthenticated("invalid credential".into()))
151 }
152}
153
154pub struct ApiKeyAuthenticator {
155 header: String,
156 store: NativeCredentialStore,
157}
158
159impl fmt::Debug for ApiKeyAuthenticator {
160 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161 f.debug_struct("ApiKeyAuthenticator")
162 .field("header", &self.header)
163 .field("store", &"[REDACTED]")
164 .finish()
165 }
166}
167
168impl ApiKeyAuthenticator {
169 pub fn new(header: String, store: NativeCredentialStore) -> Self {
170 Self { header, store }
171 }
172
173 pub fn header(&self) -> &str {
174 &self.header
175 }
176
177 pub async fn authenticate_api_key(&self, key: &str) -> Result<Principal, CamelError> {
178 self.store
179 .lookup(key)
180 .cloned()
181 .ok_or_else(|| CamelError::Unauthenticated("invalid credential".into()))
182 }
183
184 pub async fn authenticate_exchange(
185 &self,
186 exchange: &mut camel_api::Exchange,
187 ) -> Result<Principal, CamelError> {
188 let key = exchange
189 .input
190 .header_ic(&self.header)
191 .and_then(|v| v.as_str())
192 .ok_or_else(|| {
193 CamelError::Unauthenticated(format!("missing header: {}", self.header))
194 })?;
195 self.authenticate_api_key(key).await
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use crate::TokenAuthenticator;
203 use crate::built_in::RolePolicy;
204 use crate::built_in::ScopePolicy;
205 use camel_api::security_policy::SecurityPolicy;
206 use camel_api::{Exchange, Message};
207
208 fn test_principal(subject: &str, roles: Vec<&str>, scopes: Vec<&str>) -> Principal {
209 Principal {
210 subject: subject.to_string(),
211 issuer: "native".to_string(),
212 audience: vec![],
213 scopes: scopes.iter().map(|s| s.to_string()).collect(),
214 roles: roles.iter().map(|s| s.to_string()).collect(),
215 claims: serde_json::Value::Null,
216 }
217 }
218
219 #[test]
220 fn test_store_finds_matching_plaintext_credential() {
221 let store = NativeCredentialStore::try_new(vec![NativeCredential {
222 secret: NativeCredentialSecret::Plaintext {
223 value: Zeroizing::new("secret-key-123".to_string()),
224 },
225 principal: test_principal("admin", vec!["admin"], vec![]),
226 }])
227 .unwrap();
228 let found = store.lookup("secret-key-123");
229 assert!(found.is_some());
230 assert_eq!(found.unwrap().subject, "admin");
231 }
232
233 #[test]
234 fn test_store_returns_none_on_no_match() {
235 let store = NativeCredentialStore::try_new(vec![NativeCredential {
236 secret: NativeCredentialSecret::Plaintext {
237 value: Zeroizing::new("secret-key-123".to_string()),
238 },
239 principal: test_principal("admin", vec!["admin"], vec![]),
240 }])
241 .unwrap();
242 let found = store.lookup("wrong-key");
243 assert!(found.is_none());
244 }
245
246 #[test]
247 fn test_store_returns_none_on_empty_input() {
248 let store = NativeCredentialStore::try_new(vec![NativeCredential {
249 secret: NativeCredentialSecret::Plaintext {
250 value: Zeroizing::new("secret-key-123".to_string()),
251 },
252 principal: test_principal("admin", vec!["admin"], vec![]),
253 }])
254 .unwrap();
255 let found = store.lookup("");
256 assert!(found.is_none());
257 }
258
259 #[test]
260 fn test_store_resolves_env_var() {
261 let key = format!("TEST_NATIVE_AUTH_KEY_{}", std::process::id());
262 unsafe { std::env::set_var(&key, "env-secret-value") };
264 let store = NativeCredentialStore::try_new(vec![NativeCredential {
265 secret: NativeCredentialSecret::Env { name: key.clone() },
266 principal: test_principal("env-user", vec!["user"], vec![]),
267 }])
268 .unwrap();
269 let found = store.lookup("env-secret-value");
270 assert!(found.is_some());
271 assert_eq!(found.unwrap().subject, "env-user");
272 unsafe { std::env::remove_var(&key) };
274 }
275
276 #[test]
277 fn test_store_rejects_missing_env_var() {
278 let result = NativeCredentialStore::try_new(vec![NativeCredential {
279 secret: NativeCredentialSecret::Env {
280 name: "SURELY_MISSING_ENV_VAR_XYZ_12345".to_string(),
281 },
282 principal: test_principal("bad", vec![], vec![]),
283 }]);
284 assert!(result.is_err());
285 }
286
287 #[test]
288 fn test_store_rejects_empty_plaintext() {
289 let result = NativeCredentialStore::try_new(vec![NativeCredential {
290 secret: NativeCredentialSecret::Plaintext {
291 value: Zeroizing::new("".to_string()),
292 },
293 principal: test_principal("bad", vec![], vec![]),
294 }]);
295 assert!(result.is_err());
296 }
297
298 #[test]
299 fn test_store_accepts_plaintext_for_dev() {
300 let store = NativeCredentialStore::try_new(vec![NativeCredential {
301 secret: NativeCredentialSecret::Plaintext {
302 value: Zeroizing::new("insecure".to_string()),
303 },
304 principal: test_principal("dev", vec![], vec![]),
305 }])
306 .unwrap();
307 assert!(store.lookup("insecure").is_some());
308 }
309
310 #[tokio::test]
311 async fn test_static_token_authenticator_valid_token() {
312 let store = NativeCredentialStore::try_new(vec![NativeCredential {
313 secret: NativeCredentialSecret::Plaintext {
314 value: Zeroizing::new("my-bearer-token".to_string()),
315 },
316 principal: test_principal("svc-account", vec!["service"], vec![]),
317 }])
318 .unwrap();
319 let auth = StaticTokenAuthenticator::new(store);
320 let result = auth.authenticate_bearer("my-bearer-token").await;
321 assert!(result.is_ok());
322 assert_eq!(result.unwrap().subject, "svc-account");
323 }
324
325 #[tokio::test]
326 async fn test_static_token_authenticator_invalid_token() {
327 let store = NativeCredentialStore::try_new(vec![NativeCredential {
328 secret: NativeCredentialSecret::Plaintext {
329 value: Zeroizing::new("my-bearer-token".to_string()),
330 },
331 principal: test_principal("svc-account", vec!["service"], vec![]),
332 }])
333 .unwrap();
334 let auth = StaticTokenAuthenticator::new(store);
335 let result = auth.authenticate_bearer("wrong-token").await;
336 assert!(result.is_err());
337 match result.unwrap_err() {
338 CamelError::Unauthenticated(msg) => {
339 assert!(msg.contains("invalid credential"))
340 }
341 e => panic!("expected Unauthenticated, got: {e:?}"),
342 }
343 }
344
345 #[tokio::test]
346 async fn test_api_key_authenticator_valid_key() {
347 let store = NativeCredentialStore::try_new(vec![NativeCredential {
348 secret: NativeCredentialSecret::Plaintext {
349 value: Zeroizing::new("ak-12345".to_string()),
350 },
351 principal: test_principal("api-user", vec!["read"], vec!["api:read"]),
352 }])
353 .unwrap();
354 let auth = ApiKeyAuthenticator::new("x-api-key".to_string(), store);
355 let result = auth.authenticate_api_key("ak-12345").await;
356 assert!(result.is_ok());
357 assert_eq!(result.unwrap().subject, "api-user");
358 }
359
360 #[tokio::test]
361 async fn test_api_key_authenticator_invalid_key() {
362 let store = NativeCredentialStore::try_new(vec![NativeCredential {
363 secret: NativeCredentialSecret::Plaintext {
364 value: Zeroizing::new("ak-12345".to_string()),
365 },
366 principal: test_principal("api-user", vec!["read"], vec![]),
367 }])
368 .unwrap();
369 let auth = ApiKeyAuthenticator::new("x-api-key".to_string(), store);
370 let result = auth.authenticate_api_key("wrong").await;
371 assert!(result.is_err());
372 }
373
374 #[tokio::test]
375 async fn test_api_key_authenticate_exchange() {
376 let store = NativeCredentialStore::try_new(vec![NativeCredential {
377 secret: NativeCredentialSecret::Plaintext {
378 value: Zeroizing::new("ak-exchange".to_string()),
379 },
380 principal: test_principal("ex-user", vec!["read"], vec![]),
381 }])
382 .unwrap();
383 let auth = ApiKeyAuthenticator::new("x-api-key".to_string(), store);
384 let mut exchange = Exchange::new(Message::default());
385 exchange.input.set_header("x-api-key", "ak-exchange");
386 let result = auth.authenticate_exchange(&mut exchange).await;
387 assert!(result.is_ok());
388 assert_eq!(result.unwrap().subject, "ex-user");
389 }
390
391 #[tokio::test]
392 async fn test_api_key_authenticate_exchange_missing_header() {
393 let store = NativeCredentialStore::try_new(vec![NativeCredential {
394 secret: NativeCredentialSecret::Plaintext {
395 value: Zeroizing::new("ak-exchange".to_string()),
396 },
397 principal: test_principal("ex-user", vec!["read"], vec![]),
398 }])
399 .unwrap();
400 let auth = ApiKeyAuthenticator::new("x-api-key".to_string(), store);
401 let mut exchange = Exchange::new(Message::default());
402 let result = auth.authenticate_exchange(&mut exchange).await;
403 assert!(result.is_err());
404 }
405
406 #[test]
407 fn test_api_key_authenticator_exposes_header() {
408 let store = NativeCredentialStore::try_new(vec![]).unwrap();
409 let auth = ApiKeyAuthenticator::new("x-api-key".to_string(), store);
410 assert_eq!(auth.header(), "x-api-key");
411 }
412
413 #[tokio::test]
414 async fn test_static_token_works_with_role_policy() {
415 let store = NativeCredentialStore::try_new(vec![NativeCredential {
416 secret: NativeCredentialSecret::Plaintext {
417 value: Zeroizing::new("test-token".to_string()),
418 },
419 principal: test_principal("admin-user", vec!["admin"], vec![]),
420 }])
421 .unwrap();
422 let authenticator: std::sync::Arc<dyn TokenAuthenticator> =
423 std::sync::Arc::new(StaticTokenAuthenticator::new(store));
424 let policy = RolePolicy::new(vec!["admin".to_string()], true, false, authenticator);
425 let mut exchange = Exchange::new(Message::default());
426 exchange
427 .input
428 .set_header("authorization", "Bearer test-token");
429 let decision = policy.evaluate(&mut exchange).await.unwrap();
430 assert!(matches!(
431 decision,
432 camel_api::security_policy::AuthorizationDecision::Granted { .. }
433 ));
434 }
435
436 #[tokio::test]
437 async fn test_static_token_works_with_scope_policy() {
438 let store = NativeCredentialStore::try_new(vec![NativeCredential {
439 secret: NativeCredentialSecret::Plaintext {
440 value: Zeroizing::new("scoped-token".to_string()),
441 },
442 principal: test_principal("reader", vec![], vec!["api:read"]),
443 }])
444 .unwrap();
445 let authenticator: std::sync::Arc<dyn TokenAuthenticator> =
446 std::sync::Arc::new(StaticTokenAuthenticator::new(store));
447 let policy = ScopePolicy::new(vec!["api:read".to_string()], true, false, authenticator);
448 let mut exchange = Exchange::new(Message::default());
449 exchange
450 .input
451 .set_header("authorization", "Bearer scoped-token");
452 let decision = policy.evaluate(&mut exchange).await.unwrap();
453 assert!(matches!(
454 decision,
455 camel_api::security_policy::AuthorizationDecision::Granted { .. }
456 ));
457 }
458
459 #[tokio::test]
460 async fn test_static_token_denied_by_role_policy() {
461 let store = NativeCredentialStore::try_new(vec![NativeCredential {
462 secret: NativeCredentialSecret::Plaintext {
463 value: Zeroizing::new("user-token".to_string()),
464 },
465 principal: test_principal("user", vec!["user"], vec![]),
466 }])
467 .unwrap();
468 let authenticator: std::sync::Arc<dyn TokenAuthenticator> =
469 std::sync::Arc::new(StaticTokenAuthenticator::new(store));
470 let policy = RolePolicy::new(vec!["admin".to_string()], true, false, authenticator);
471 let mut exchange = Exchange::new(Message::default());
472 exchange
473 .input
474 .set_header("authorization", "Bearer user-token");
475 let decision = policy.evaluate(&mut exchange).await.unwrap();
476 assert!(matches!(
477 decision,
478 camel_api::security_policy::AuthorizationDecision::Denied { .. }
479 ));
480 }
481}