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::CredentialSource;
208    use camel_api::security_policy::SecurityPolicy;
209    use camel_api::{Exchange, Message};
210
211    fn test_principal(subject: &str, roles: Vec<&str>, scopes: Vec<&str>) -> Principal {
212        Principal {
213            subject: subject.to_string(),
214            issuer: "native".to_string(),
215            audience: vec![],
216            scopes: scopes.iter().map(|s| s.to_string()).collect(),
217            roles: roles.iter().map(|s| s.to_string()).collect(),
218            claims: serde_json::Value::Null,
219        }
220    }
221
222    #[test]
223    fn test_store_finds_matching_plaintext_credential() {
224        let store = NativeCredentialStore::try_new(vec![NativeCredential {
225            secret: NativeCredentialSecret::Plaintext {
226                value: Zeroizing::new("secret-key-123".to_string()),
227            },
228            principal: test_principal("admin", vec!["admin"], vec![]),
229        }])
230        .unwrap();
231        let found = store.lookup("secret-key-123");
232        assert!(found.is_some());
233        assert_eq!(found.unwrap().subject, "admin");
234    }
235
236    #[test]
237    fn test_store_returns_none_on_no_match() {
238        let store = NativeCredentialStore::try_new(vec![NativeCredential {
239            secret: NativeCredentialSecret::Plaintext {
240                value: Zeroizing::new("secret-key-123".to_string()),
241            },
242            principal: test_principal("admin", vec!["admin"], vec![]),
243        }])
244        .unwrap();
245        let found = store.lookup("wrong-key");
246        assert!(found.is_none());
247    }
248
249    #[test]
250    fn test_store_returns_none_on_empty_input() {
251        let store = NativeCredentialStore::try_new(vec![NativeCredential {
252            secret: NativeCredentialSecret::Plaintext {
253                value: Zeroizing::new("secret-key-123".to_string()),
254            },
255            principal: test_principal("admin", vec!["admin"], vec![]),
256        }])
257        .unwrap();
258        let found = store.lookup("");
259        assert!(found.is_none());
260    }
261
262    #[test]
263    fn test_store_resolves_env_var() {
264        let key = format!("TEST_NATIVE_AUTH_KEY_{}", std::process::id());
265        // SAFETY: test-only env mutation; no concurrent tests touch this key.
266        unsafe { std::env::set_var(&key, "env-secret-value") };
267        let store = NativeCredentialStore::try_new(vec![NativeCredential {
268            secret: NativeCredentialSecret::Env { name: key.clone() },
269            principal: test_principal("env-user", vec!["user"], vec![]),
270        }])
271        .unwrap();
272        let found = store.lookup("env-secret-value");
273        assert!(found.is_some());
274        assert_eq!(found.unwrap().subject, "env-user");
275        // SAFETY: cleanup of test-only env var.
276        unsafe { std::env::remove_var(&key) };
277    }
278
279    #[test]
280    fn test_store_rejects_missing_env_var() {
281        let result = NativeCredentialStore::try_new(vec![NativeCredential {
282            secret: NativeCredentialSecret::Env {
283                name: "SURELY_MISSING_ENV_VAR_XYZ_12345".to_string(),
284            },
285            principal: test_principal("bad", vec![], vec![]),
286        }]);
287        assert!(result.is_err());
288    }
289
290    #[test]
291    fn test_store_rejects_empty_plaintext() {
292        let result = NativeCredentialStore::try_new(vec![NativeCredential {
293            secret: NativeCredentialSecret::Plaintext {
294                value: Zeroizing::new("".to_string()),
295            },
296            principal: test_principal("bad", vec![], vec![]),
297        }]);
298        assert!(result.is_err());
299    }
300
301    #[test]
302    fn test_store_accepts_plaintext_for_dev() {
303        let store = NativeCredentialStore::try_new(vec![NativeCredential {
304            secret: NativeCredentialSecret::Plaintext {
305                value: Zeroizing::new("insecure".to_string()),
306            },
307            principal: test_principal("dev", vec![], vec![]),
308        }])
309        .unwrap();
310        assert!(store.lookup("insecure").is_some());
311    }
312
313    #[tokio::test]
314    async fn test_static_token_authenticator_valid_token() {
315        let store = NativeCredentialStore::try_new(vec![NativeCredential {
316            secret: NativeCredentialSecret::Plaintext {
317                value: Zeroizing::new("my-bearer-token".to_string()),
318            },
319            principal: test_principal("svc-account", vec!["service"], vec![]),
320        }])
321        .unwrap();
322        let auth = StaticTokenAuthenticator::new(store);
323        let result = auth.authenticate_bearer("my-bearer-token").await;
324        assert!(result.is_ok());
325        assert_eq!(result.unwrap().subject, "svc-account");
326    }
327
328    #[tokio::test]
329    async fn test_static_token_authenticator_invalid_token() {
330        let store = NativeCredentialStore::try_new(vec![NativeCredential {
331            secret: NativeCredentialSecret::Plaintext {
332                value: Zeroizing::new("my-bearer-token".to_string()),
333            },
334            principal: test_principal("svc-account", vec!["service"], vec![]),
335        }])
336        .unwrap();
337        let auth = StaticTokenAuthenticator::new(store);
338        let result = auth.authenticate_bearer("wrong-token").await;
339        assert!(result.is_err());
340        match result.unwrap_err() {
341            CamelError::Unauthenticated(msg) => {
342                assert!(msg.contains("invalid credential"))
343            }
344            e => panic!("expected Unauthenticated, got: {e:?}"),
345        }
346    }
347
348    #[tokio::test]
349    async fn test_api_key_authenticator_valid_key() {
350        let store = NativeCredentialStore::try_new(vec![NativeCredential {
351            secret: NativeCredentialSecret::Plaintext {
352                value: Zeroizing::new("ak-12345".to_string()),
353            },
354            principal: test_principal("api-user", vec!["read"], vec!["api:read"]),
355        }])
356        .unwrap();
357        let auth = ApiKeyAuthenticator::new("x-api-key".to_string(), store);
358        let result = auth.authenticate_api_key("ak-12345").await;
359        assert!(result.is_ok());
360        assert_eq!(result.unwrap().subject, "api-user");
361    }
362
363    #[tokio::test]
364    async fn test_api_key_authenticator_invalid_key() {
365        let store = NativeCredentialStore::try_new(vec![NativeCredential {
366            secret: NativeCredentialSecret::Plaintext {
367                value: Zeroizing::new("ak-12345".to_string()),
368            },
369            principal: test_principal("api-user", vec!["read"], vec![]),
370        }])
371        .unwrap();
372        let auth = ApiKeyAuthenticator::new("x-api-key".to_string(), store);
373        let result = auth.authenticate_api_key("wrong").await;
374        assert!(result.is_err());
375    }
376
377    #[tokio::test]
378    async fn test_api_key_authenticate_exchange() {
379        let store = NativeCredentialStore::try_new(vec![NativeCredential {
380            secret: NativeCredentialSecret::Plaintext {
381                value: Zeroizing::new("ak-exchange".to_string()),
382            },
383            principal: test_principal("ex-user", vec!["read"], vec![]),
384        }])
385        .unwrap();
386        let auth = ApiKeyAuthenticator::new("x-api-key".to_string(), store);
387        let mut exchange = Exchange::new(Message::default());
388        exchange.input.set_header("x-api-key", "ak-exchange");
389        let result = auth.authenticate_exchange(&mut exchange).await;
390        assert!(result.is_ok());
391        assert_eq!(result.unwrap().subject, "ex-user");
392    }
393
394    #[tokio::test]
395    async fn test_api_key_authenticate_exchange_missing_header() {
396        let store = NativeCredentialStore::try_new(vec![NativeCredential {
397            secret: NativeCredentialSecret::Plaintext {
398                value: Zeroizing::new("ak-exchange".to_string()),
399            },
400            principal: test_principal("ex-user", vec!["read"], vec![]),
401        }])
402        .unwrap();
403        let auth = ApiKeyAuthenticator::new("x-api-key".to_string(), store);
404        let mut exchange = Exchange::new(Message::default());
405        let result = auth.authenticate_exchange(&mut exchange).await;
406        assert!(result.is_err());
407    }
408
409    #[test]
410    fn test_api_key_authenticator_exposes_header() {
411        let store = NativeCredentialStore::try_new(vec![]).unwrap();
412        let auth = ApiKeyAuthenticator::new("x-api-key".to_string(), store);
413        assert_eq!(auth.header(), "x-api-key");
414    }
415
416    #[tokio::test]
417    async fn test_static_token_works_with_role_policy() {
418        let store = NativeCredentialStore::try_new(vec![NativeCredential {
419            secret: NativeCredentialSecret::Plaintext {
420                value: Zeroizing::new("test-token".to_string()),
421            },
422            principal: test_principal("admin-user", vec!["admin"], vec![]),
423        }])
424        .unwrap();
425        let authenticator: std::sync::Arc<dyn TokenAuthenticator> =
426            std::sync::Arc::new(StaticTokenAuthenticator::new(store));
427        let policy = RolePolicy::new(
428            vec!["admin".to_string()],
429            true,
430            false,
431            authenticator,
432            vec![CredentialSource::AuthorizationHeader],
433        );
434        let mut exchange = Exchange::new(Message::default());
435        exchange
436            .input
437            .set_header("authorization", "Bearer test-token");
438        let decision = policy.evaluate(&mut exchange).await.unwrap();
439        assert!(matches!(
440            decision,
441            camel_api::security_policy::AuthorizationDecision::Granted { .. }
442        ));
443    }
444
445    #[tokio::test]
446    async fn test_static_token_works_with_scope_policy() {
447        let store = NativeCredentialStore::try_new(vec![NativeCredential {
448            secret: NativeCredentialSecret::Plaintext {
449                value: Zeroizing::new("scoped-token".to_string()),
450            },
451            principal: test_principal("reader", vec![], vec!["api:read"]),
452        }])
453        .unwrap();
454        let authenticator: std::sync::Arc<dyn TokenAuthenticator> =
455            std::sync::Arc::new(StaticTokenAuthenticator::new(store));
456        let policy = ScopePolicy::new(
457            vec!["api:read".to_string()],
458            true,
459            false,
460            authenticator,
461            vec![CredentialSource::AuthorizationHeader],
462        );
463        let mut exchange = Exchange::new(Message::default());
464        exchange
465            .input
466            .set_header("authorization", "Bearer scoped-token");
467        let decision = policy.evaluate(&mut exchange).await.unwrap();
468        assert!(matches!(
469            decision,
470            camel_api::security_policy::AuthorizationDecision::Granted { .. }
471        ));
472    }
473
474    #[tokio::test]
475    async fn test_static_token_denied_by_role_policy() {
476        let store = NativeCredentialStore::try_new(vec![NativeCredential {
477            secret: NativeCredentialSecret::Plaintext {
478                value: Zeroizing::new("user-token".to_string()),
479            },
480            principal: test_principal("user", vec!["user"], vec![]),
481        }])
482        .unwrap();
483        let authenticator: std::sync::Arc<dyn TokenAuthenticator> =
484            std::sync::Arc::new(StaticTokenAuthenticator::new(store));
485        let policy = RolePolicy::new(
486            vec!["admin".to_string()],
487            true,
488            false,
489            authenticator,
490            vec![CredentialSource::AuthorizationHeader],
491        );
492        let mut exchange = Exchange::new(Message::default());
493        exchange
494            .input
495            .set_header("authorization", "Bearer user-token");
496        let decision = policy.evaluate(&mut exchange).await.unwrap();
497        assert!(matches!(
498            decision,
499            camel_api::security_policy::AuthorizationDecision::Denied { .. }
500        ));
501    }
502}