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