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::CredentialSource;
175 use camel_api::security_policy::SecurityPolicy;
176 use camel_api::{Exchange, Message};
177
178 fn test_principal(subject: &str, roles: Vec<&str>, scopes: Vec<&str>) -> Principal {
179 Principal {
180 subject: subject.to_string(),
181 issuer: "native".to_string(),
182 audience: vec![],
183 scopes: scopes.iter().map(|s| s.to_string()).collect(),
184 roles: roles.iter().map(|s| s.to_string()).collect(),
185 claims: serde_json::Value::Null,
186 }
187 }
188
189 #[test]
190 fn test_store_finds_matching_plaintext_credential() {
191 let store = NativeCredentialStore::try_new(vec![NativeCredential {
192 secret: NativeCredentialSecret::Plaintext {
193 value: Zeroizing::new("secret-key-123".to_string()),
194 },
195 principal: test_principal("admin", vec!["admin"], vec![]),
196 }])
197 .unwrap();
198 let found = store.lookup("secret-key-123");
199 assert!(found.is_some());
200 assert_eq!(found.unwrap().subject, "admin");
201 }
202
203 #[test]
204 fn test_store_returns_none_on_no_match() {
205 let store = NativeCredentialStore::try_new(vec![NativeCredential {
206 secret: NativeCredentialSecret::Plaintext {
207 value: Zeroizing::new("secret-key-123".to_string()),
208 },
209 principal: test_principal("admin", vec!["admin"], vec![]),
210 }])
211 .unwrap();
212 let found = store.lookup("wrong-key");
213 assert!(found.is_none());
214 }
215
216 #[test]
217 fn test_store_returns_none_on_empty_input() {
218 let store = NativeCredentialStore::try_new(vec![NativeCredential {
219 secret: NativeCredentialSecret::Plaintext {
220 value: Zeroizing::new("secret-key-123".to_string()),
221 },
222 principal: test_principal("admin", vec!["admin"], vec![]),
223 }])
224 .unwrap();
225 let found = store.lookup("");
226 assert!(found.is_none());
227 }
228
229 #[test]
230 fn test_store_resolves_env_var() {
231 let key = format!("TEST_NATIVE_AUTH_KEY_{}", std::process::id());
232 unsafe { std::env::set_var(&key, "env-secret-value") };
234 let store = NativeCredentialStore::try_new(vec![NativeCredential {
235 secret: NativeCredentialSecret::Env { name: key.clone() },
236 principal: test_principal("env-user", vec!["user"], vec![]),
237 }])
238 .unwrap();
239 let found = store.lookup("env-secret-value");
240 assert!(found.is_some());
241 assert_eq!(found.unwrap().subject, "env-user");
242 unsafe { std::env::remove_var(&key) };
244 }
245
246 #[test]
247 fn test_store_rejects_missing_env_var() {
248 let result = NativeCredentialStore::try_new(vec![NativeCredential {
249 secret: NativeCredentialSecret::Env {
250 name: "SURELY_MISSING_ENV_VAR_XYZ_12345".to_string(),
251 },
252 principal: test_principal("bad", vec![], vec![]),
253 }]);
254 assert!(result.is_err());
255 }
256
257 #[test]
258 fn test_store_rejects_empty_plaintext() {
259 let result = NativeCredentialStore::try_new(vec![NativeCredential {
260 secret: NativeCredentialSecret::Plaintext {
261 value: Zeroizing::new("".to_string()),
262 },
263 principal: test_principal("bad", vec![], vec![]),
264 }]);
265 assert!(result.is_err());
266 }
267
268 #[test]
269 fn test_store_accepts_plaintext_for_dev() {
270 let store = NativeCredentialStore::try_new(vec![NativeCredential {
271 secret: NativeCredentialSecret::Plaintext {
272 value: Zeroizing::new("insecure".to_string()),
273 },
274 principal: test_principal("dev", vec![], vec![]),
275 }])
276 .unwrap();
277 assert!(store.lookup("insecure").is_some());
278 }
279
280 #[test]
281 fn store_rejects_marker_secret() {
282 let result = NativeCredentialStore::try_new(vec![NativeCredential {
283 secret: NativeCredentialSecret::Plaintext {
284 value: Zeroizing::new("{{env:X}}".to_string()),
285 },
286 principal: test_principal("bad", vec![], vec![]),
287 }]);
288 let err = match result {
289 Ok(_) => panic!("marker secret must be rejected"),
290 Err(err) => err,
291 };
292 assert!(
293 err.to_string().contains("marker"),
294 "unexpected error: {err}"
295 );
296 }
297
298 #[test]
299 fn store_accepts_clean_secret() {
300 let store = NativeCredentialStore::try_new(vec![NativeCredential {
301 secret: NativeCredentialSecret::Plaintext {
302 value: Zeroizing::new("clean-secret-value".to_string()),
303 },
304 principal: test_principal("clean-user", vec![], vec![]),
305 }])
306 .unwrap();
307 let found = store.lookup("clean-secret-value");
308 assert!(found.is_some());
309 assert_eq!(found.unwrap().subject, "clean-user");
310 }
311
312 #[tokio::test]
313 async fn test_static_token_authenticator_valid_token() {
314 let store = NativeCredentialStore::try_new(vec![NativeCredential {
315 secret: NativeCredentialSecret::Plaintext {
316 value: Zeroizing::new("my-bearer-token".to_string()),
317 },
318 principal: test_principal("svc-account", vec!["service"], vec![]),
319 }])
320 .unwrap();
321 let auth = StaticTokenAuthenticator::new(store);
322 let result = auth.authenticate_bearer("my-bearer-token").await;
323 assert!(result.is_ok());
324 assert_eq!(result.unwrap().subject, "svc-account");
325 }
326
327 #[tokio::test]
328 async fn test_static_token_authenticator_invalid_token() {
329 let store = NativeCredentialStore::try_new(vec![NativeCredential {
330 secret: NativeCredentialSecret::Plaintext {
331 value: Zeroizing::new("my-bearer-token".to_string()),
332 },
333 principal: test_principal("svc-account", vec!["service"], vec![]),
334 }])
335 .unwrap();
336 let auth = StaticTokenAuthenticator::new(store);
337 let result = auth.authenticate_bearer("wrong-token").await;
338 assert!(result.is_err());
339 match result.unwrap_err() {
340 CamelError::Unauthenticated(msg) => {
341 assert!(msg.contains("invalid credential"))
342 }
343 e => panic!("expected Unauthenticated, got: {e:?}"),
344 }
345 }
346
347 #[tokio::test]
348 async fn test_static_token_works_with_role_policy() {
349 let store = NativeCredentialStore::try_new(vec![NativeCredential {
350 secret: NativeCredentialSecret::Plaintext {
351 value: Zeroizing::new("test-token".to_string()),
352 },
353 principal: test_principal("admin-user", vec!["admin"], vec![]),
354 }])
355 .unwrap();
356 let authenticator: std::sync::Arc<dyn TokenAuthenticator> =
357 std::sync::Arc::new(StaticTokenAuthenticator::new(store));
358 let policy = RolePolicy::new(
359 vec!["admin".to_string()],
360 true,
361 false,
362 authenticator,
363 vec![CredentialSource::AuthorizationHeader],
364 );
365 let mut exchange = Exchange::new(Message::default());
366 exchange
367 .input
368 .set_header("authorization", "Bearer test-token");
369 let decision = policy.evaluate(&mut exchange).await.unwrap();
370 assert!(matches!(
371 decision,
372 camel_api::security_policy::AuthorizationDecision::Granted { .. }
373 ));
374 }
375
376 #[tokio::test]
377 async fn test_static_token_works_with_scope_policy() {
378 let store = NativeCredentialStore::try_new(vec![NativeCredential {
379 secret: NativeCredentialSecret::Plaintext {
380 value: Zeroizing::new("scoped-token".to_string()),
381 },
382 principal: test_principal("reader", vec![], vec!["api:read"]),
383 }])
384 .unwrap();
385 let authenticator: std::sync::Arc<dyn TokenAuthenticator> =
386 std::sync::Arc::new(StaticTokenAuthenticator::new(store));
387 let policy = ScopePolicy::new(
388 vec!["api:read".to_string()],
389 true,
390 false,
391 authenticator,
392 vec![CredentialSource::AuthorizationHeader],
393 );
394 let mut exchange = Exchange::new(Message::default());
395 exchange
396 .input
397 .set_header("authorization", "Bearer scoped-token");
398 let decision = policy.evaluate(&mut exchange).await.unwrap();
399 assert!(matches!(
400 decision,
401 camel_api::security_policy::AuthorizationDecision::Granted { .. }
402 ));
403 }
404
405 #[tokio::test]
406 async fn test_static_token_denied_by_role_policy() {
407 let store = NativeCredentialStore::try_new(vec![NativeCredential {
408 secret: NativeCredentialSecret::Plaintext {
409 value: Zeroizing::new("user-token".to_string()),
410 },
411 principal: test_principal("user", vec!["user"], vec![]),
412 }])
413 .unwrap();
414 let authenticator: std::sync::Arc<dyn TokenAuthenticator> =
415 std::sync::Arc::new(StaticTokenAuthenticator::new(store));
416 let policy = RolePolicy::new(
417 vec!["admin".to_string()],
418 true,
419 false,
420 authenticator,
421 vec![CredentialSource::AuthorizationHeader],
422 );
423 let mut exchange = Exchange::new(Message::default());
424 exchange
425 .input
426 .set_header("authorization", "Bearer user-token");
427 let decision = policy.evaluate(&mut exchange).await.unwrap();
428 assert!(matches!(
429 decision,
430 camel_api::security_policy::AuthorizationDecision::Denied { .. }
431 ));
432 }
433}