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