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
16pub 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
43fn 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}"))) } else {
52 DecodingKey::from_rsa_components(n, e)
53 .map_err(|e| AuthError::TokenInvalid(format!("invalid JWK components: {e}"))) }
55}
56
57const EXPECTED_ALG: &str = "RS256";
59
60fn 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 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 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 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 let mut validation = Validation::new(Algorithm::RS256);
104 validation.set_audience(&self.audience);
105 validation.set_issuer(&[&self.issuer]);
106
107 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 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 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 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![]) }
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 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 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 "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 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 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 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 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 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}