Skip to main content

camel_auth/
token_authenticator.rs

1use async_trait::async_trait;
2use camel_api::CamelError;
3use camel_api::security_policy::{Principal, TransportId};
4
5use crate::jwt::JwtValidator;
6
7/// Per-request authentication context: the token plus the route/provider
8/// audience and issuer constraints to enforce.
9///
10/// `audiences` and `accepted_issuers` are the request-scoped sets. When either
11/// is non-empty the authenticator enforces it (REPLACEMENT semantics, bypassing
12/// constructor-fixed checks); when both are empty the authenticator falls back
13/// to its constructor-fixed behavior.
14pub struct AuthnRequest<'a> {
15    pub token: &'a str,
16    pub audiences: &'a [String],
17    pub accepted_issuers: &'a [String],
18    pub transport: TransportId,
19}
20
21/// Separates authentication (token → Principal) from authorization (SecurityPolicy check).
22///
23/// Provides a blanket implementation for any [`JwtValidator`], converting
24/// provider-specific [`AuthError`](crate::types::AuthError) variants into
25/// domain-level [`CamelError`] variants.
26#[async_trait]
27pub trait TokenAuthenticator: Send + Sync {
28    /// Authenticate a Bearer token and return the associated [`Principal`].
29    async fn authenticate_bearer(&self, token: &str) -> Result<Principal, CamelError>;
30
31    /// Authenticate a token against per-request audience/issuer constraints.
32    ///
33    /// The default delegates to [`authenticate_bearer`](Self::authenticate_bearer),
34    /// preserving constructor-fixed behavior for implementors that do not
35    /// distinguish request-scoped constraints.
36    async fn authenticate(&self, req: AuthnRequest<'_>) -> Result<Principal, CamelError> {
37        self.authenticate_bearer(req.token).await
38    }
39}
40
41#[async_trait]
42impl<T: JwtValidator> TokenAuthenticator for T {
43    async fn authenticate_bearer(&self, token: &str) -> Result<Principal, CamelError> {
44        self.validate(token).await.map_err(CamelError::from)
45    }
46
47    async fn authenticate(&self, req: AuthnRequest<'_>) -> Result<Principal, CamelError> {
48        // No request-scoped constraints → constructor-fixed behavior via delegation.
49        if req.audiences.is_empty() && req.accepted_issuers.is_empty() {
50            return self.authenticate_bearer(req.token).await;
51        }
52
53        // REPLACEMENT semantics: signature-only verification, then enforce the
54        // request's issuer/audience sets (constructor-fixed checks bypassed).
55        let principal = self
56            .validate_signature(req.token)
57            .await
58            .map_err(CamelError::from)?;
59
60        if !req.accepted_issuers.is_empty()
61            && !req.accepted_issuers.iter().any(|i| i == &principal.issuer)
62        {
63            return Err(CamelError::Unauthenticated(format!(
64                "token issuer {:?} not in accepted set", // allow-secret
65                principal.issuer
66            )));
67        }
68
69        if !req.audiences.is_empty()
70            && !principal.audience.iter().any(|a| req.audiences.contains(a))
71        {
72            return Err(CamelError::Unauthenticated(format!(
73                "token audience {:?} not in accepted set", // allow-secret
74                principal.audience
75            )));
76        }
77
78        Ok(principal)
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use crate::types::AuthError;
86    use serde_json::json;
87
88    struct MockValidator {
89        principal: Option<Principal>,
90        should_fail: bool,
91    }
92
93    #[async_trait]
94    impl JwtValidator for MockValidator {
95        async fn validate(&self, _token: &str) -> Result<Principal, AuthError> {
96            if self.should_fail {
97                return Err(AuthError::TokenInvalid("bad token".into()));
98            }
99            self.principal
100                .clone()
101                .ok_or_else(|| AuthError::TokenInvalid("no principal".into()))
102        }
103    }
104
105    fn test_principal() -> Principal {
106        Principal {
107            subject: "user1".into(),
108            issuer: "test-issuer".into(),
109            audience: vec!["api".into()],
110            scopes: vec!["read".into()],
111            roles: vec!["admin".into()],
112            claims: json!({"sub": "user1"}),
113        }
114    }
115
116    #[tokio::test]
117    async fn test_authenticate_bearer_success() {
118        let validator = MockValidator {
119            principal: Some(test_principal()),
120            should_fail: false,
121        };
122        let result = validator.authenticate_bearer("valid-token").await;
123        assert!(result.is_ok());
124        assert_eq!(result.unwrap().subject, "user1");
125    }
126
127    #[tokio::test]
128    async fn test_authenticate_bearer_invalid_token() {
129        let validator = MockValidator {
130            principal: None,
131            should_fail: true,
132        };
133        let err = validator.authenticate_bearer("bad").await.unwrap_err();
134        match err {
135            CamelError::Unauthenticated(msg) => assert!(msg.contains("bad token")),
136            _ => panic!("expected Unauthenticated, got: {err:?}"),
137        }
138    }
139
140    #[tokio::test]
141    async fn test_authenticate_bearer_provider_unavailable() {
142        struct UnavailableValidator;
143        #[async_trait]
144        impl JwtValidator for UnavailableValidator {
145            async fn validate(&self, _token: &str) -> Result<Principal, AuthError> {
146                Err(AuthError::ProviderUnavailable("connection refused".into()))
147            }
148        }
149        let err = UnavailableValidator
150            .authenticate_bearer("token")
151            .await
152            .unwrap_err();
153        match err {
154            CamelError::ProcessorError(msg) => assert!(msg.contains("auth provider unavailable")),
155            _ => panic!("expected ProcessorError, got: {err:?}"),
156        }
157    }
158
159    #[tokio::test]
160    async fn test_authenticate_bearer_token_expired() {
161        struct ExpiredValidator;
162        #[async_trait]
163        impl JwtValidator for ExpiredValidator {
164            async fn validate(&self, _token: &str) -> Result<Principal, AuthError> {
165                Err(AuthError::TokenExpired)
166            }
167        }
168        let err = ExpiredValidator
169            .authenticate_bearer("expired-token")
170            .await
171            .unwrap_err();
172        match err {
173            CamelError::Unauthenticated(msg) => assert!(msg.contains("token expired")),
174            _ => panic!("expected Unauthenticated, got: {err:?}"),
175        }
176    }
177
178    #[tokio::test]
179    async fn test_authenticate_bearer_unauthorized() {
180        struct UnauthorizedValidator;
181        #[async_trait]
182        impl JwtValidator for UnauthorizedValidator {
183            async fn validate(&self, _token: &str) -> Result<Principal, AuthError> {
184                Err(AuthError::Unauthorized("insufficient permissions".into()))
185            }
186        }
187        let err = UnauthorizedValidator
188            .authenticate_bearer("token")
189            .await
190            .unwrap_err();
191        match err {
192            CamelError::Unauthorized(msg) => assert!(msg.contains("insufficient permissions")),
193            _ => panic!("expected Unauthorized, got: {err:?}"),
194        }
195    }
196
197    #[tokio::test]
198    async fn test_authenticate_bearer_config_error() {
199        struct ConfigErrorValidator;
200        #[async_trait]
201        impl JwtValidator for ConfigErrorValidator {
202            async fn validate(&self, _token: &str) -> Result<Principal, AuthError> {
203                Err(AuthError::ConfigError("missing issuer".into()))
204            }
205        }
206        let err = ConfigErrorValidator
207            .authenticate_bearer("token")
208            .await
209            .unwrap_err();
210        match err {
211            CamelError::Config(msg) => assert!(msg.contains("missing issuer")),
212            _ => panic!("expected Config, got: {err:?}"),
213        }
214    }
215
216    // --- Task 3.1: per-request audience/issuer enforcement ---
217
218    use crate::claims::{ClaimPaths, JsonPointerClaimsMapper};
219    use crate::jwks::{Jwk, JwksProvider};
220    use crate::jwt::LocalJwtValidator;
221    use std::sync::Arc;
222
223    static TEST_RSA_PRIVATE_PEM: &[u8] = include_bytes!("../tests/fixtures/test_rsa_private.pem");
224    static TEST_RSA_PUBLIC_PEM: &[u8] = include_bytes!("../tests/fixtures/test_rsa_public.pem");
225
226    struct MockJwks {
227        kid: String,
228        public_pem: &'static [u8],
229    }
230
231    #[async_trait]
232    impl JwksProvider for MockJwks {
233        async fn get_signing_keys(&self) -> Result<Vec<Jwk>, AuthError> {
234            Ok(vec![Jwk {
235                kid: self.kid.clone(),
236                kty: "RSA".into(),
237                alg: Some("RS256".into()),
238                r#use: None,
239                n: String::from_utf8_lossy(self.public_pem).into_owned(),
240                e: "AQAB".into(),
241            }])
242        }
243
244        async fn refresh(&self) -> Result<(), AuthError> {
245            Ok(())
246        }
247    }
248
249    fn jwt_validator(audience: Vec<&str>, issuer: &str) -> LocalJwtValidator {
250        let mapper = Arc::new(JsonPointerClaimsMapper::new(ClaimPaths {
251            subject: "/sub".into(),
252            roles: vec!["/groups".into()],
253            scopes: Some("/scope".into()),
254        }));
255        LocalJwtValidator::new(
256            audience.iter().map(|s| s.to_string()).collect(),
257            issuer.to_string(),
258            Arc::new(MockJwks {
259                kid: "test-key".into(),
260                public_pem: TEST_RSA_PUBLIC_PEM,
261            }),
262            mapper,
263        )
264    }
265
266    fn make_token(kid: &str, claims: &serde_json::Value) -> String {
267        let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
268        header.kid = Some(kid.to_string());
269        let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(TEST_RSA_PRIVATE_PEM).unwrap();
270        jsonwebtoken::encode(&header, claims, &encoding_key).unwrap()
271    }
272
273    fn valid_claims(iss: &str, aud: &str) -> serde_json::Value {
274        let now = chrono::Utc::now().timestamp() as u64;
275        json!({
276            "sub": "user-1",
277            "iss": iss,
278            "aud": aud,
279            "exp": now + 3600,
280            "iat": now,
281        })
282    }
283
284    fn req<'a>(token: &'a str, audiences: &'a [String], issuers: &'a [String]) -> AuthnRequest<'a> {
285        AuthnRequest {
286            token,
287            audiences,
288            accepted_issuers: issuers,
289            transport: TransportId::Http,
290        }
291    }
292
293    #[tokio::test]
294    async fn issuer_not_accepted_rejects() {
295        let v = jwt_validator(vec!["api"], "https://a");
296        let token = make_token("test-key", &valid_claims("https://b", "api"));
297        let err = v
298            .authenticate(req(
299                &token,
300                &["api".to_string()],
301                &["https://a".to_string()],
302            ))
303            .await
304            .unwrap_err();
305        assert!(matches!(err, CamelError::Unauthenticated(_)));
306    }
307
308    #[tokio::test]
309    async fn audience_mismatch_rejects() {
310        let v = jwt_validator(vec!["api"], "https://a");
311        let token = make_token("test-key", &valid_claims("https://a", "api-b"));
312        let err = v
313            .authenticate(req(
314                &token,
315                &["api-a".to_string(), "api-c".to_string()],
316                &["https://a".to_string()],
317            ))
318            .await
319            .unwrap_err();
320        assert!(matches!(err, CamelError::Unauthenticated(_)));
321    }
322
323    #[tokio::test]
324    async fn request_audience_overrides_constructor_default() {
325        // Constructor-fixed audience ["api"]; request audiences ["api-2"]; token aud "api-2".
326        let v = jwt_validator(vec!["api"], "https://a");
327        let token = make_token("test-key", &valid_claims("https://a", "api-2"));
328
329        // Request-scoped check active → fixed check bypassed → OK.
330        let principal = v
331            .authenticate(req(
332                &token,
333                &["api-2".to_string()],
334                &["https://a".to_string()],
335            ))
336            .await
337            .unwrap();
338        assert_eq!(principal.subject, "user-1");
339
340        // Empty request audiences → constructor behavior via delegation (fixed
341        // check rejects aud "api-2" against constructor audience ["api"]).
342        let err = v.authenticate(req(&token, &[], &[])).await.unwrap_err();
343        assert!(matches!(err, CamelError::Unauthenticated(_)));
344    }
345
346    #[tokio::test]
347    async fn default_delegation_backcompat() {
348        struct OnlyBearer;
349        #[async_trait]
350        impl TokenAuthenticator for OnlyBearer {
351            async fn authenticate_bearer(&self, _token: &str) -> Result<Principal, CamelError> {
352                Ok(test_principal())
353            }
354        }
355        let principal = OnlyBearer
356            .authenticate(req("tok", &["api".to_string()], &["https://a".to_string()]))
357            .await
358            .unwrap();
359        assert_eq!(principal.subject, "user1");
360    }
361
362    #[tokio::test]
363    async fn empty_issuers_accepts_any() {
364        let v = jwt_validator(vec!["api"], "https://a");
365        let token = make_token("test-key", &valid_claims("https://anything", "api"));
366        let principal = v
367            .authenticate(req(&token, &["api".to_string()], &[]))
368            .await
369            .unwrap();
370        assert_eq!(principal.subject, "user-1");
371    }
372
373    #[tokio::test]
374    async fn empty_constructor_audience_fails_closed() {
375        // Constructor audience empty; empty request sets → delegation path
376        // (fixed-claims check). Empty constructor audience must reject
377        // (fail-closed), not authenticate with zero audience scoping.
378        let v = jwt_validator(vec![], "https://a");
379        let token = make_token("test-key", &valid_claims("https://a", "api"));
380        let err = v.authenticate(req(&token, &[], &[])).await.unwrap_err();
381        assert!(matches!(err, CamelError::Unauthenticated(_)));
382    }
383
384    #[tokio::test]
385    async fn empty_constructor_issuer_fails_closed() {
386        // Issuer direction of the same invariant: empty constructor issuer
387        // on the delegation path must reject, not accept any issuer.
388        let v = jwt_validator(vec!["api"], "");
389        let token = make_token("test-key", &valid_claims("https://a", "api"));
390        let err = v.authenticate(req(&token, &[], &[])).await.unwrap_err();
391        assert!(matches!(err, CamelError::Unauthenticated(_)));
392    }
393
394    #[tokio::test]
395    async fn empty_constructor_audience_replaced_by_request_audiences() {
396        // Constructor audience empty; non-empty request audiences → REPLACEMENT
397        // path (signature-only + request checks). The request set replaces the
398        // constructor-fixed check, so a matching request audience grants.
399        let v = jwt_validator(vec![], "https://a");
400        let token = make_token("test-key", &valid_claims("https://a", "api"));
401        let principal = v
402            .authenticate(req(
403                &token,
404                &["api".to_string()],
405                &["https://a".to_string()],
406            ))
407            .await
408            .unwrap();
409        assert_eq!(principal.subject, "user-1");
410    }
411}