Skip to main content

camel_auth/
jwt.rs

1use async_trait::async_trait;
2use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
3use std::sync::Arc;
4
5use crate::claims::ClaimsMapper;
6use crate::jwks::{Jwk, JwksProvider};
7use crate::types::AuthError;
8use camel_api::security_policy::Principal;
9
10/// Validates JWT tokens and extracts a [`Principal`].
11#[async_trait]
12pub trait JwtValidator: Send + Sync {
13    async fn validate(&self, token: &str) -> Result<Principal, AuthError>;
14
15    /// Signature-only verification: constructor keyset verification, NO
16    /// issuer/audience check.
17    ///
18    /// The default delegates to [`validate`](Self::validate) for implementors
19    /// that do not distinguish signature-only from full validation.
20    async fn validate_signature(&self, token: &str) -> Result<Principal, AuthError> {
21        self.validate(token).await
22    }
23}
24
25/// Production JWT validator backed by a dynamic JWKS provider.
26///
27/// Delegates Principal construction to a configurable [`ClaimsMapper`],
28/// allowing provider-specific claim shapes without hardcoding extraction logic.
29pub struct LocalJwtValidator {
30    audience: Vec<String>,
31    issuer: String,
32    jwks: Arc<dyn JwksProvider>,
33    mapper: Arc<dyn ClaimsMapper>,
34}
35
36impl LocalJwtValidator {
37    pub fn new(
38        audience: Vec<String>,
39        issuer: String,
40        jwks: Arc<dyn JwksProvider>,
41        mapper: Arc<dyn ClaimsMapper>,
42    ) -> Self {
43        Self {
44            audience,
45            issuer,
46            jwks,
47            mapper,
48        }
49    }
50}
51
52/// Convert a JWK to a [`DecodingKey`].
53///
54/// Supports both PEM-encoded public keys (stored in `n` with a `-----BEGIN` prefix,
55/// useful for testing) and standard JWKS base64url components (production).
56fn jwk_to_decoding_key(n: &str, e: &str) -> Result<DecodingKey, AuthError> {
57    if n.starts_with("-----BEGIN") {
58        DecodingKey::from_rsa_pem(n.as_bytes())
59            .map_err(|e| AuthError::TokenInvalid(format!("invalid RSA PEM: {e}"))) // allow-secret
60    } else {
61        DecodingKey::from_rsa_components(n, e)
62            .map_err(|e| AuthError::TokenInvalid(format!("invalid JWK components: {e}"))) // allow-secret
63    }
64}
65
66/// Expected signing algorithm — must match [`Validation::new(Algorithm::RS256)`].
67const EXPECTED_ALG: &str = "RS256";
68
69/// Returns `true` if the JWK matches `kid` and passes alg/use filters.
70///
71/// A JWK is considered acceptable when:
72/// - Its `kid` matches the token header.
73/// - Its `alg` is either absent (spec default) or equals [`EXPECTED_ALG`].
74/// - Its `use` is either absent (spec default) or equals `"sig"`.
75fn key_matches(k: &Jwk, kid: &str) -> bool {
76    k.kid == kid
77        && k.alg.as_deref().is_none_or(|a| a == EXPECTED_ALG)
78        && k.r#use.as_deref().is_none_or(|u| u == "sig")
79}
80
81#[async_trait]
82impl JwtValidator for LocalJwtValidator {
83    async fn validate(&self, token: &str) -> Result<Principal, AuthError> {
84        let principal = self.validate_signature(token).await?;
85
86        // Fixed-claims-check: enforce the constructor-configured audience/issuer.
87        //
88        // Fail-closed: an empty constructor audience/issuer rejects. This
89        // byte-preserves the old jsonwebtoken behavior where an empty configured
90        // set matched nothing (reject-all), preventing an empty-audience config
91        // from authenticating with zero audience scoping.
92        if self.audience.is_empty() || !principal.audience.iter().any(|a| self.audience.contains(a))
93        {
94            return Err(AuthError::TokenInvalid("invalid audience".into()));
95        }
96        if self.issuer.is_empty() || principal.issuer != self.issuer {
97            return Err(AuthError::TokenInvalid("invalid issuer".into()));
98        }
99
100        Ok(principal)
101    }
102
103    async fn validate_signature(&self, token: &str) -> Result<Principal, AuthError> {
104        // Decode header to extract kid
105        let header = decode_header(token)
106            .map_err(|e| AuthError::TokenInvalid(format!("invalid JWT header: {e}")))?;
107
108        let kid = header
109            .kid
110            .ok_or_else(|| AuthError::TokenInvalid("JWT missing kid".into()))?;
111
112        // Fetch signing keys; on kid miss, force a JWKS refresh (handles key rotation)
113        let keys = self.jwks.get_signing_keys().await?;
114        let jwk = if let Some(k) = keys.iter().find(|k| key_matches(k, &kid)) {
115            k.clone()
116        } else {
117            // Key not in cache — might be a newly rotated key; refresh once and retry
118            self.jwks.refresh().await?;
119            self.jwks
120                .get_signing_keys()
121                .await?
122                .into_iter()
123                .find(|k| key_matches(k, &kid))
124                .ok_or_else(|| {
125                    AuthError::TokenInvalid(format!("no key for kid={kid} after refresh"))
126                })?
127        };
128
129        let decoding_key = jwk_to_decoding_key(&jwk.n, &jwk.e)?;
130
131        // Configure validation — signature/validity only, NO issuer/audience check.
132        let mut validation = Validation::new(Algorithm::RS256);
133        validation.validate_aud = false;
134
135        // Decode and verify
136        let token_data =
137            decode::<serde_json::Value>(token, &decoding_key, &validation).map_err(|e| match e
138                .kind()
139            {
140                jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::TokenExpired,
141                _ => AuthError::TokenInvalid(e.to_string()),
142            })?;
143
144        let claims = token_data.claims;
145
146        // Delegate Principal construction to the configured ClaimsMapper
147        self.mapper.to_principal(&claims)
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::claims::{ClaimPaths, JsonPointerClaimsMapper};
155    use crate::jwks::Jwk;
156    use jsonwebtoken::{EncodingKey, Header, encode};
157    use serde_json::json;
158
159    static TEST_RSA_PRIVATE_PEM: &[u8] = include_bytes!("../tests/fixtures/test_rsa_private.pem");
160    static TEST_RSA_PUBLIC_PEM: &[u8] = include_bytes!("../tests/fixtures/test_rsa_public.pem");
161
162    /// Mock JWKS provider that returns a PEM-encoded public key.
163    struct MockJwks {
164        kid: String,
165        public_pem: &'static [u8],
166    }
167
168    #[async_trait]
169    impl JwksProvider for MockJwks {
170        async fn get_signing_keys(&self) -> Result<Vec<Jwk>, AuthError> {
171            Ok(vec![Jwk {
172                kid: self.kid.clone(),
173                kty: "RSA".into(),
174                alg: Some("RS256".into()),
175                r#use: None,
176                n: String::from_utf8_lossy(self.public_pem).into_owned(),
177                e: "AQAB".into(),
178            }])
179        }
180
181        async fn refresh(&self) -> Result<(), AuthError> {
182            Ok(())
183        }
184    }
185
186    /// Mock JWKS that starts empty and gains a key after refresh (simulates rotation).
187    struct RotatingMockJwks {
188        kid: String,
189        public_pem: &'static [u8],
190        refreshed: std::sync::atomic::AtomicBool,
191    }
192
193    #[async_trait]
194    impl JwksProvider for RotatingMockJwks {
195        async fn get_signing_keys(&self) -> Result<Vec<Jwk>, AuthError> {
196            if self.refreshed.load(std::sync::atomic::Ordering::SeqCst) {
197                Ok(vec![Jwk {
198                    kid: self.kid.clone(),
199                    kty: "RSA".into(),
200                    alg: Some("RS256".into()),
201                    r#use: None,
202                    n: String::from_utf8_lossy(self.public_pem).into_owned(),
203                    e: "AQAB".into(),
204                }])
205            } else {
206                Ok(vec![]) // key not yet known
207            }
208        }
209
210        async fn refresh(&self) -> Result<(), AuthError> {
211            self.refreshed
212                .store(true, std::sync::atomic::Ordering::SeqCst);
213            Ok(())
214        }
215    }
216
217    /// Build a mapper configured for multiple role paths.
218    fn multi_role_mapper(role_paths: Vec<String>) -> Arc<JsonPointerClaimsMapper> {
219        Arc::new(JsonPointerClaimsMapper::new(ClaimPaths {
220            subject: "/sub".into(),
221            roles: role_paths,
222            scopes: Some("/scope".into()),
223        }))
224    }
225
226    fn validator(audience: Vec<&str>, mapper: Arc<dyn ClaimsMapper>) -> LocalJwtValidator {
227        LocalJwtValidator::new(
228            audience.iter().map(|s| s.to_string()).collect(),
229            "http://localhost:8080/realms/test".into(),
230            Arc::new(MockJwks {
231                kid: "test-key".into(),
232                public_pem: TEST_RSA_PUBLIC_PEM,
233            }),
234            mapper,
235        )
236    }
237
238    fn validator_with_jwk(jwk: Jwk, mapper: Arc<dyn ClaimsMapper>) -> LocalJwtValidator {
239        struct SingleKeyJwks {
240            jwk: Jwk,
241        }
242
243        #[async_trait]
244        impl JwksProvider for SingleKeyJwks {
245            async fn get_signing_keys(&self) -> Result<Vec<Jwk>, AuthError> {
246                Ok(vec![self.jwk.clone()])
247            }
248            async fn refresh(&self) -> Result<(), AuthError> {
249                Ok(())
250            }
251        }
252
253        LocalJwtValidator::new(
254            vec!["my-api".into()],
255            "http://localhost:8080/realms/test".into(),
256            Arc::new(SingleKeyJwks { jwk }),
257            mapper,
258        )
259    }
260
261    /// Standard claims set valid for "my-api" audience, used by multiple tests.
262    fn claims_with_defaults() -> serde_json::Value {
263        let now = chrono::Utc::now().timestamp() as u64;
264        json!({
265            "sub": "user-123",
266            "iss": "http://localhost:8080/realms/test",
267            "aud": "my-api",
268            "exp": now + 3600,
269            "iat": now,
270        })
271    }
272
273    fn make_token(kid: &str, claims: &serde_json::Value) -> String {
274        let mut header = Header::new(Algorithm::RS256);
275        header.kid = Some(kid.to_string());
276        let encoding_key = EncodingKey::from_rsa_pem(TEST_RSA_PRIVATE_PEM).unwrap();
277        encode(&header, claims, &encoding_key).unwrap()
278    }
279
280    #[tokio::test]
281    async fn validates_valid_token() {
282        let v = validator(vec!["my-api"], multi_role_mapper(vec!["/groups".into()]));
283        let now = chrono::Utc::now().timestamp() as u64;
284        let claims = json!({
285            "sub": "user-123",
286            "iss": "http://localhost:8080/realms/test",
287            "aud": "my-api",
288            "exp": now + 3600,
289            "iat": now,
290        });
291        let token = make_token("test-key", &claims);
292        let principal = v.validate(&token).await.unwrap();
293        assert_eq!(principal.subject, "user-123");
294    }
295
296    #[tokio::test]
297    async fn rejects_expired_token() {
298        let v = validator(vec!["my-api"], multi_role_mapper(vec!["/groups".into()]));
299        let now = chrono::Utc::now().timestamp() as u64;
300        let claims = json!({
301            "sub": "user-123",
302            "iss": "http://localhost:8080/realms/test",
303            "aud": "my-api",
304            "exp": now - 3600,
305            "iat": now - 7200,
306        });
307        let token = make_token("test-key", &claims);
308        assert!(matches!(
309            v.validate(&token).await,
310            Err(AuthError::TokenExpired)
311        ));
312    }
313
314    #[tokio::test]
315    async fn rejects_wrong_audience() {
316        let v = validator(vec!["my-api"], multi_role_mapper(vec!["/groups".into()]));
317        let now = chrono::Utc::now().timestamp() as u64;
318        let claims = json!({
319            "sub": "user-123",
320            "iss": "http://localhost:8080/realms/test",
321            "aud": "wrong-audience",
322            "exp": now + 3600,
323            "iat": now,
324        });
325        let token = make_token("test-key", &claims);
326        assert!(matches!(
327            v.validate(&token).await,
328            Err(AuthError::TokenInvalid(_))
329        ));
330    }
331
332    #[tokio::test]
333    async fn extracts_resource_access_roles() {
334        let mapper = multi_role_mapper(vec![
335            "/realm_access/roles".into(),
336            "/resource_access/my-client/roles".into(),
337        ]);
338        let v = validator(vec!["my-client"], mapper);
339        let now = chrono::Utc::now().timestamp() as u64;
340        let claims = json!({
341            "sub": "user-123",
342            "iss": "http://localhost:8080/realms/test",
343            "aud": "my-client",
344            "exp": now + 3600,
345            "iat": now,
346            "realm_access": { "roles": ["realm-role"] },
347            "resource_access": {
348                "my-client": { "roles": ["client-role-a"] }
349            },
350        });
351        let token = make_token("test-key", &claims);
352        let principal = v.validate(&token).await.unwrap();
353        assert!(principal.has_role("realm-role"));
354        assert!(principal.has_role("client-role-a"));
355    }
356
357    #[tokio::test]
358    async fn rejects_missing_sub() {
359        let v = validator(vec!["my-api"], multi_role_mapper(vec!["/groups".into()]));
360        let now = chrono::Utc::now().timestamp() as u64;
361        let claims = json!({
362            // "sub" intentionally absent
363            "iss": "http://localhost:8080/realms/test",
364            "aud": "my-api",
365            "exp": now + 3600,
366            "iat": now,
367        });
368        let token = make_token("test-key", &claims);
369        assert!(matches!(
370            v.validate(&token).await,
371            Err(AuthError::TokenInvalid(_))
372        ));
373    }
374
375    #[tokio::test]
376    async fn refreshes_on_unknown_kid() {
377        let now = chrono::Utc::now().timestamp() as u64;
378        let claims = json!({
379            "sub": "user-123",
380            "iss": "http://localhost:8080/realms/test",
381            "aud": "my-api",
382            "exp": now + 3600,
383            "iat": now,
384        });
385        let token = make_token("test-key", &claims);
386
387        // Validator backed by a JWKS that returns the key only after refresh
388        let v = LocalJwtValidator::new(
389            vec!["my-api".into()],
390            "http://localhost:8080/realms/test".into(),
391            Arc::new(RotatingMockJwks {
392                kid: "test-key".into(),
393                public_pem: TEST_RSA_PUBLIC_PEM,
394                refreshed: std::sync::atomic::AtomicBool::new(false),
395            }),
396            multi_role_mapper(vec!["/groups".into()]),
397        );
398
399        // Token should validate after the forced JWKS refresh
400        let principal = v.validate(&token).await.unwrap();
401        assert_eq!(principal.subject, "user-123");
402    }
403
404    #[tokio::test]
405    async fn mapper_configures_role_paths_independently_of_audience() {
406        // Mapper is configured with explicit role paths — no audience heuristic needed.
407        // Token audience is "other-audience" but mapper looks up roles under "my-service".
408        let mapper = multi_role_mapper(vec![
409            "/realm_access/roles".into(),
410            "/resource_access/my-service/roles".into(),
411        ]);
412        let v = validator(vec!["other-audience"], mapper);
413
414        let now = chrono::Utc::now().timestamp() as u64;
415        let claims = json!({
416            "sub": "user-123",
417            "iss": "http://localhost:8080/realms/test",
418            "aud": "other-audience",
419            "exp": now + 3600,
420            "iat": now,
421            "resource_access": {
422                "my-service": { "roles": ["svc-role"] },
423                "other-audience": { "roles": ["aud-role"] },
424            },
425        });
426        let token = make_token("test-key", &claims);
427        let principal = v.validate(&token).await.unwrap();
428
429        // Mapper finds "svc-role" under "my-service" via configured path,
430        // NOT "aud-role" under "other-audience".
431        assert!(
432            principal.has_role("svc-role"),
433            "expected svc-role from my-service path"
434        );
435        assert!(
436            !principal.has_role("aud-role"),
437            "must not pick aud-role when mapper path targets my-service"
438        );
439    }
440
441    #[tokio::test]
442    async fn extracts_scopes_from_scope_claim() {
443        let mapper = multi_role_mapper(vec!["/groups".into()]);
444        let v = validator(vec!["my-api"], mapper);
445        let now = chrono::Utc::now().timestamp() as u64;
446        let claims = json!({
447            "sub": "user-123",
448            "iss": "http://localhost:8080/realms/test",
449            "aud": "my-api",
450            "exp": now + 3600,
451            "iat": now,
452            "scope": "read write admin",
453        });
454        let token = make_token("test-key", &claims);
455        let principal = v.validate(&token).await.unwrap();
456        assert_eq!(principal.scopes, vec!["read", "write", "admin"]);
457    }
458
459    #[tokio::test]
460    async fn rejects_key_with_alg_mismatch() {
461        let jwk = Jwk {
462            kid: "test-key".into(),
463            kty: "RSA".into(),
464            alg: Some("HS256".into()),
465            r#use: None,
466            n: String::from_utf8_lossy(TEST_RSA_PUBLIC_PEM).into_owned(),
467            e: "AQAB".into(),
468        };
469        let v = validator_with_jwk(jwk, multi_role_mapper(vec!["/groups".into()]));
470        let token = make_token("test-key", &claims_with_defaults());
471        assert!(matches!(
472            v.validate(&token).await,
473            Err(AuthError::TokenInvalid(_))
474        ));
475    }
476
477    #[tokio::test]
478    async fn rejects_key_with_use_mismatch() {
479        let jwk = Jwk {
480            kid: "test-key".into(),
481            kty: "RSA".into(),
482            alg: Some("RS256".into()),
483            r#use: Some("enc".into()),
484            n: String::from_utf8_lossy(TEST_RSA_PUBLIC_PEM).into_owned(),
485            e: "AQAB".into(),
486        };
487        let v = validator_with_jwk(jwk, multi_role_mapper(vec!["/groups".into()]));
488        let token = make_token("test-key", &claims_with_defaults());
489        assert!(matches!(
490            v.validate(&token).await,
491            Err(AuthError::TokenInvalid(_))
492        ));
493    }
494
495    #[tokio::test]
496    async fn accepts_key_without_alg_or_use() {
497        let jwk = Jwk {
498            kid: "test-key".into(),
499            kty: "RSA".into(),
500            alg: None,
501            r#use: None,
502            n: String::from_utf8_lossy(TEST_RSA_PUBLIC_PEM).into_owned(),
503            e: "AQAB".into(),
504        };
505        let v = validator_with_jwk(jwk, multi_role_mapper(vec!["/groups".into()]));
506        let token = make_token("test-key", &claims_with_defaults());
507        let principal = v.validate(&token).await.unwrap();
508        assert_eq!(principal.subject, "user-123");
509    }
510
511    #[tokio::test]
512    async fn extracts_generic_groups_roles() {
513        // Test with generic /groups claim path
514        let mapper = multi_role_mapper(vec!["/groups".into()]);
515        let v = validator(vec!["my-api"], mapper);
516        let now = chrono::Utc::now().timestamp() as u64;
517        let claims = json!({
518            "sub": "user-123",
519            "iss": "http://localhost:8080/realms/test",
520            "aud": "my-api",
521            "exp": now + 3600,
522            "iat": now,
523            "groups": ["admin", "editor", "viewer"],
524        });
525        let token = make_token("test-key", &claims);
526        let principal = v.validate(&token).await.unwrap();
527        assert!(principal.has_role("admin"));
528        assert!(principal.has_role("editor"));
529        assert!(principal.has_role("viewer"));
530    }
531}