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