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