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") };
253 let store = NativeCredentialStore::try_new(vec![NativeCredential {
254 secret: NativeCredentialSecret::Env { name: key.clone() },
255 principal: test_principal("env-user", vec!["user"], vec![]),
256 }])
257 .unwrap();
258 let found = store.lookup("env-secret-value");
259 assert!(found.is_some());
260 assert_eq!(found.unwrap().subject, "env-user");
261 unsafe { std::env::remove_var(&key) };
264 }
265
266 #[test]
267 fn test_store_rejects_missing_env_var() {
268 let result = NativeCredentialStore::try_new(vec![NativeCredential {
269 secret: NativeCredentialSecret::Env {
270 name: "SURELY_MISSING_ENV_VAR_XYZ_12345".to_string(),
271 },
272 principal: test_principal("bad", vec![], vec![]),
273 }]);
274 assert!(result.is_err());
275 }
276
277 #[test]
278 fn test_store_rejects_empty_plaintext() {
279 let result = NativeCredentialStore::try_new(vec![NativeCredential {
280 secret: NativeCredentialSecret::Plaintext {
281 value: Zeroizing::new("".to_string()),
282 },
283 principal: test_principal("bad", vec![], vec![]),
284 }]);
285 assert!(result.is_err());
286 }
287
288 #[test]
289 fn test_store_accepts_plaintext_for_dev() {
290 let store = NativeCredentialStore::try_new(vec![NativeCredential {
291 secret: NativeCredentialSecret::Plaintext {
292 value: Zeroizing::new("insecure".to_string()),
293 },
294 principal: test_principal("dev", vec![], vec![]),
295 }])
296 .unwrap();
297 assert!(store.lookup("insecure").is_some());
298 }
299
300 #[test]
301 fn store_rejects_marker_secret() {
302 let result = NativeCredentialStore::try_new(vec![NativeCredential {
303 secret: NativeCredentialSecret::Plaintext {
304 value: Zeroizing::new("{{env:X}}".to_string()),
305 },
306 principal: test_principal("bad", vec![], vec![]),
307 }]);
308 let err = match result {
309 Ok(_) => panic!("marker secret must be rejected"),
310 Err(err) => err,
311 };
312 assert!(
313 err.to_string().contains("marker"),
314 "unexpected error: {err}"
315 );
316 }
317
318 #[test]
319 fn store_accepts_clean_secret() {
320 let store = NativeCredentialStore::try_new(vec![NativeCredential {
321 secret: NativeCredentialSecret::Plaintext {
322 value: Zeroizing::new("clean-secret-value".to_string()),
323 },
324 principal: test_principal("clean-user", vec![], vec![]),
325 }])
326 .unwrap();
327 let found = store.lookup("clean-secret-value");
328 assert!(found.is_some());
329 assert_eq!(found.unwrap().subject, "clean-user");
330 }
331
332 #[test]
333 fn store_guard_rejects_new_syntax_marker() {
334 let result = NativeCredentialStore::try_new(vec![NativeCredential {
335 secret: NativeCredentialSecret::Plaintext {
336 value: Zeroizing::new("x${env:y}z".to_string()),
337 },
338 principal: test_principal("bad", vec![], vec![]),
339 }]);
340 let err = match result {
341 Ok(_) => panic!("marker secret must be rejected"),
342 Err(err) => err,
343 };
344 assert!(
345 err.to_string().contains("marker"),
346 "unexpected error: {err}"
347 );
348 }
349
350 #[test]
351 fn store_guard_accepts_clean_secrets() {
352 let store_a = NativeCredentialStore::try_new(vec![NativeCredential {
354 secret: NativeCredentialSecret::Plaintext {
355 value: Zeroizing::new("a$b".to_string()),
356 },
357 principal: test_principal("dollar-user", vec![], vec![]),
358 }]);
359 assert!(
360 store_a.is_ok(),
361 "store guard must accept secret with lone $: {:?}",
362 store_a.err()
363 );
364
365 let store_b = NativeCredentialStore::try_new(vec![NativeCredential {
367 secret: NativeCredentialSecret::Plaintext {
368 value: Zeroizing::new("opaque-token-xyz-123".to_string()),
369 },
370 principal: test_principal("token-user", vec![], vec![]),
371 }]);
372 assert!(
373 store_b.is_ok(),
374 "store guard must accept normal opaque token: {:?}",
375 store_b.err()
376 );
377 }
378
379 #[tokio::test]
380 async fn test_static_token_authenticator_valid_token() {
381 let store = NativeCredentialStore::try_new(vec![NativeCredential {
382 secret: NativeCredentialSecret::Plaintext {
383 value: Zeroizing::new("my-bearer-token".to_string()),
384 },
385 principal: test_principal("svc-account", vec!["service"], vec![]),
386 }])
387 .unwrap();
388 let auth = StaticTokenAuthenticator::new(store);
389 let result = auth.authenticate_bearer("my-bearer-token").await;
390 assert!(result.is_ok());
391 assert_eq!(result.unwrap().subject, "svc-account");
392 }
393
394 #[tokio::test]
395 async fn test_static_token_authenticator_invalid_token() {
396 let store = NativeCredentialStore::try_new(vec![NativeCredential {
397 secret: NativeCredentialSecret::Plaintext {
398 value: Zeroizing::new("my-bearer-token".to_string()),
399 },
400 principal: test_principal("svc-account", vec!["service"], vec![]),
401 }])
402 .unwrap();
403 let auth = StaticTokenAuthenticator::new(store);
404 let result = auth.authenticate_bearer("wrong-token").await;
405 assert!(result.is_err());
406 match result.unwrap_err() {
407 CamelError::Unauthenticated(msg) => {
408 assert!(msg.contains("invalid credential"))
409 }
410 e => panic!("expected Unauthenticated, got: {e:?}"),
411 }
412 }
413
414 #[tokio::test]
415 async fn test_static_token_works_with_role_policy() {
416 let store = NativeCredentialStore::try_new(vec![NativeCredential {
417 secret: NativeCredentialSecret::Plaintext {
418 value: Zeroizing::new("test-token".to_string()),
419 },
420 principal: test_principal("admin-user", vec!["admin"], vec![]),
421 }])
422 .unwrap();
423 let authenticator: std::sync::Arc<dyn TokenAuthenticator> =
424 std::sync::Arc::new(StaticTokenAuthenticator::new(store));
425 let principal = authenticator
426 .authenticate_bearer("test-token")
427 .await
428 .unwrap();
429 let typed = TestPrincipal(principal);
430 let policy = RolePolicy::new(vec!["admin".to_string()], true);
431 let mut exchange = Exchange::new(Message::default());
432 let auth = auth_ctx(&typed);
433 let decision = policy.evaluate(&mut exchange, &auth).await.unwrap();
434 assert!(matches!(
435 decision,
436 camel_api::security_policy::AuthorizationDecision::Granted { .. }
437 ));
438 }
439
440 #[tokio::test]
441 async fn test_static_token_works_with_scope_policy() {
442 let store = NativeCredentialStore::try_new(vec![NativeCredential {
443 secret: NativeCredentialSecret::Plaintext {
444 value: Zeroizing::new("scoped-token".to_string()),
445 },
446 principal: test_principal("reader", vec![], vec!["api:read"]),
447 }])
448 .unwrap();
449 let authenticator: std::sync::Arc<dyn TokenAuthenticator> =
450 std::sync::Arc::new(StaticTokenAuthenticator::new(store));
451 let principal = authenticator
452 .authenticate_bearer("scoped-token")
453 .await
454 .unwrap();
455 let typed = TestPrincipal(principal);
456 let policy = ScopePolicy::new(vec!["api:read".to_string()], true);
457 let mut exchange = Exchange::new(Message::default());
458 let auth = auth_ctx(&typed);
459 let decision = policy.evaluate(&mut exchange, &auth).await.unwrap();
460 assert!(matches!(
461 decision,
462 camel_api::security_policy::AuthorizationDecision::Granted { .. }
463 ));
464 }
465
466 #[tokio::test]
467 async fn test_static_token_denied_by_role_policy() {
468 let store = NativeCredentialStore::try_new(vec![NativeCredential {
469 secret: NativeCredentialSecret::Plaintext {
470 value: Zeroizing::new("user-token".to_string()),
471 },
472 principal: test_principal("user", vec!["user"], vec![]),
473 }])
474 .unwrap();
475 let authenticator: std::sync::Arc<dyn TokenAuthenticator> =
476 std::sync::Arc::new(StaticTokenAuthenticator::new(store));
477 let principal = authenticator
478 .authenticate_bearer("user-token")
479 .await
480 .unwrap();
481 let typed = TestPrincipal(principal);
482 let policy = RolePolicy::new(vec!["admin".to_string()], true);
483 let mut exchange = Exchange::new(Message::default());
484 let auth = auth_ctx(&typed);
485 let decision = policy.evaluate(&mut exchange, &auth).await.unwrap();
486 assert!(matches!(
487 decision,
488 camel_api::security_policy::AuthorizationDecision::Denied { .. }
489 ));
490 }
491}