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