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