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        // env-guard-exception: camel-auth cannot depend on camel-component-api
250        // (component-api depends on camel-auth: publish cycle), so the canonical
251        // EnvGuard is unavailable in this crate's unit tests.
252        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        // env-guard-exception: same publish-cycle constraint; remove the
262        // test-only variable without an EnvGuard.
263        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        // standalone escape result — lone `$` must not be treated as a marker
353        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        // normal opaque token — no markers at all
366        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}