Skip to main content

camel_auth/
native_auth.rs

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/// ADR-0051 credential boundary: manual-redaction
14#[derive(Clone)]
15pub enum NativeCredentialSecret {
16    Env { name: String },
17    Plaintext { value: Zeroizing<String> },
18}
19
20/// ADR-0051 credential boundary: manual-redaction
21#[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}\" }}") // allow-secret
36            }
37            NativeCredentialSecret::Plaintext { .. } => {
38                write!(f, "Plaintext {{ value: \"[REDACTED]\" }}") // allow-secret
39            }
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
70/// Defense-in-depth: reject credentials that still carry unresolved
71/// placeholder markers (`{{` or `${`) at the authenticator boundary.
72pub 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        // SAFETY: test-only env mutation; no concurrent tests touch this key.
250        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        // SAFETY: cleanup of test-only env var.
260        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        // standalone escape result — lone `$` must not be treated as a marker
350        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        // normal opaque token — no markers at all
363        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}