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    // ---- RemoteJwksProvider-backed validator harness (jwks-refresh-guard) ----
512
513    use std::time::Duration;
514
515    use crate::jwks::RemoteJwksProvider;
516
517    /// JWKS document carrying the test public PEM in `n` (PEM-in-`n` convention
518    /// accepted by `jwk_to_decoding_key`).
519    fn jwks_body(kid: &str) -> String {
520        let pem = std::str::from_utf8(TEST_RSA_PUBLIC_PEM).unwrap();
521        serde_json::json!({
522            "keys": [{
523                "kid": kid,
524                "kty": "RSA",
525                "alg": "RS256",
526                "n": pem,
527                "e": "AQAB",
528            }]
529        })
530        .to_string()
531    }
532
533    /// Validator backed by a real `RemoteJwksProvider` against a wiremock URI,
534    /// with a 100ms forced-refresh cooldown. The second handle is kept for
535    /// priming the provider cache.
536    fn remote_validator(server_uri: String) -> (LocalJwtValidator, Arc<RemoteJwksProvider>) {
537        remote_validator_with_cooldown(server_uri, Duration::from_millis(100))
538    }
539
540    /// Like `remote_validator`, but with an explicit forced-refresh cooldown so
541    /// individual tests can widen the margin against CI scheduling stalls.
542    fn remote_validator_with_cooldown(
543        server_uri: String,
544        cooldown: Duration,
545    ) -> (LocalJwtValidator, Arc<RemoteJwksProvider>) {
546        let provider = Arc::new(RemoteJwksProvider::new_for_test_with_cooldown(
547            server_uri, cooldown,
548        ));
549        let validator = LocalJwtValidator::new(
550            vec!["my-api".into()],
551            "http://localhost:8080/realms/test".into(),
552            provider.clone(),
553            multi_role_mapper(vec!["/groups".into()]),
554        );
555        (validator, provider)
556    }
557
558    #[tokio::test]
559    async fn validate_signature_concurrent_unknown_kids_bounded() {
560        use wiremock::matchers::method;
561        use wiremock::{Mock, MockServer, ResponseTemplate};
562
563        let server = MockServer::start().await;
564        Mock::given(method("GET"))
565            .respond_with(
566                ResponseTemplate::new(200)
567                    .set_body_raw(jwks_body("test-key"), "application/json")
568                    .insert_header("cache-control", "max-age=3600")
569                    .set_delay(Duration::from_millis(300)),
570            )
571            .mount(&server)
572            .await;
573
574        let (validator, provider) =
575            remote_validator_with_cooldown(server.uri(), Duration::from_millis(500));
576
577        // Prime GET: populates the cache (fetched_at = now, TTL 3600s).
578        provider.get_signing_keys().await.unwrap();
579
580        // The cache is private to `jwks`, so backdating `fetched_at` directly
581        // is impossible from this module; sleeping past the cooldown leaves
582        // the cache TTL-fresh yet outside the forced-refresh interval. The
583        // 500ms cooldown also dominates the 300ms response delay: a task
584        // queued on the mutex during the forced fetch that stalls before its
585        // post-lock re-check still sees `forced_start.elapsed()` < 500ms, so
586        // the cooldown suppresses any further GET. The 750ms aging sleep must
587        // exceed the 500ms cooldown so the single forced attempt is eligible.
588        tokio::time::sleep(Duration::from_millis(750)).await;
589
590        // 32 concurrent unknown-kid tokens; signatures are never reached on
591        // kid-miss, so this models unknown-kid attack traffic exactly.
592        let claims = claims_with_defaults();
593        let tokens: Vec<String> = (1..=32)
594            .map(|i| make_token(&format!("atk-{i}"), &claims))
595            .collect();
596
597        let validator = Arc::new(validator);
598        let handles: Vec<_> = tokens
599            .iter()
600            .map(|token| {
601                let validator = validator.clone();
602                let token = token.clone();
603                tokio::spawn(async move { validator.validate_signature(&token).await })
604            })
605            .collect();
606        for (i, handle) in handles.into_iter().enumerate() {
607            let result = handle.await.unwrap();
608            assert!(
609                matches!(result, Err(AuthError::TokenInvalid(_))),
610                "kid atk-{} must be rejected as TokenInvalid, got {result:?}",
611                i + 1
612            );
613        }
614
615        let gets = server
616            .received_requests()
617            .await
618            .unwrap()
619            .iter()
620            .filter(|r| r.method.as_str() == "GET")
621            .count();
622        assert_eq!(
623            gets, 2,
624            "prime + exactly one forced fetch: 32 unknown kids must not amplify fetches"
625        );
626    }
627
628    #[tokio::test]
629    async fn rotated_key_recovery_after_cooldown() {
630        use wiremock::matchers::method;
631        use wiremock::{Mock, MockServer, ResponseTemplate};
632
633        let server = MockServer::start().await;
634        Mock::given(method("GET"))
635            .respond_with(
636                ResponseTemplate::new(200)
637                    .set_body_raw(jwks_body("test-key"), "application/json")
638                    .insert_header("cache-control", "max-age=3600"),
639            )
640            // Same-matcher mocks are matched first-mounted-first in wiremock,
641            // so the pre-rotation mock must retire after the prime GET and
642            // the consumed forced attempt for the rotated mock to serve.
643            .up_to_n_times(2)
644            .mount(&server)
645            .await;
646
647        let (validator, provider) = remote_validator(server.uri());
648
649        // Prime GET.
650        provider.get_signing_keys().await.unwrap();
651
652        // Sleep past the 100ms cooldown so a forced attempt is eligible.
653        tokio::time::sleep(Duration::from_millis(150)).await;
654
655        // Consume one forced attempt: unknown kid → forced fetch (GET #2),
656        // kid still missing → TokenInvalid.
657        let unknown = make_token("atk-1", &claims_with_defaults());
658        assert!(matches!(
659            validator.validate_signature(&unknown).await,
660            Err(AuthError::TokenInvalid(_))
661        ));
662        assert_eq!(server.received_requests().await.unwrap().len(), 2);
663
664        // Rotation: the endpoint now serves the rotated key set.
665        Mock::given(method("GET"))
666            .respond_with(
667                ResponseTemplate::new(200)
668                    .set_body_raw(jwks_body("rotated-key"), "application/json")
669                    .insert_header("cache-control", "max-age=3600"),
670            )
671            .mount(&server)
672            .await;
673
674        // Cooldown elapsed since the first forced attempt.
675        tokio::time::sleep(Duration::from_millis(150)).await;
676
677        let rotated = make_token("rotated-key", &claims_with_defaults());
678        let principal = validator
679            .validate_signature(&rotated)
680            .await
681            .expect("rotated key must validate after the cooldown elapsed");
682        assert_eq!(principal.subject, "user-123");
683
684        let gets = server
685            .received_requests()
686            .await
687            .unwrap()
688            .iter()
689            .filter(|r| r.method.as_str() == "GET")
690            .count();
691        assert_eq!(gets, 3, "prime + consumed attempt + rotation fetch");
692    }
693
694    #[tokio::test]
695    async fn extracts_generic_groups_roles() {
696        // Test with generic /groups claim path
697        let mapper = multi_role_mapper(vec!["/groups".into()]);
698        let v = validator(vec!["my-api"], mapper);
699        let now = chrono::Utc::now().timestamp() as u64;
700        let claims = json!({
701            "sub": "user-123",
702            "iss": "http://localhost:8080/realms/test",
703            "aud": "my-api",
704            "exp": now + 3600,
705            "iat": now,
706            "groups": ["admin", "editor", "viewer"],
707        });
708        let token = make_token("test-key", &claims);
709        let principal = v.validate(&token).await.unwrap();
710        assert!(principal.has_role("admin"));
711        assert!(principal.has_role("editor"));
712        assert!(principal.has_role("viewer"));
713    }
714}