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#[async_trait]
12pub trait JwtValidator: Send + Sync {
13 async fn validate(&self, token: &str) -> Result<Principal, AuthError>;
14
15 async fn validate_signature(&self, token: &str) -> Result<Principal, AuthError> {
21 self.validate(token).await
22 }
23}
24
25pub 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
52fn 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}"))) } else {
61 DecodingKey::from_rsa_components(n, e)
62 .map_err(|e| AuthError::TokenInvalid(format!("invalid JWK components: {e}"))) }
64}
65
66const EXPECTED_ALG: &str = "RS256";
68
69fn 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 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 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 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 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 let mut validation = Validation::new(Algorithm::RS256);
133 validation.validate_aud = false;
134
135 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 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 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 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![]) }
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 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 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 "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 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 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 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 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 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}