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