Cervantes strictly separates the validation engine (cervantes-core, pure Java, no HTTP, no CDI) from the integration layers (cervantes-jaxrs for JAX-RS, cervantes-cdi-vauban for CDI). This page details the validation sequence, the JAX-RS auth filter, the @Claim synthesis BCE, the thread-safe JWKS cache, and the threading invariants.

Separation cervantes-core / integrations

cervantes-core is pure Java. It depends only on cervantes-mp-jwt-api (spec), cervantes-api (public SPI), jakarta.json (parsing through Champollion), and on the MicroProfile Config SPI as a method signature — not on any runtime implementation. It can be used outside any container: a unit test can generate an RSA pair, forge a token, and validate the whole thing without starting Cassini or Vauban.

cervantes-cdi-vauban wires the engine into CDI: @RequestScoped JsonWebToken producer, @Claim synthesis BCE, JwtValidator producer configured from MP-Config.

cervantes-jaxrs wires the engine into JAX-RS: an auth ContainerRequestFilter, a DynamicFeature for @RolesAllowed, a SecurityContext backed by the validated token. No Cassini modification was required — Cervantes uses the standard JAX-RS API (setSecurityContext, @PreMatching, @Priority(AUTHENTICATION), DynamicFeature).

Validation pipeline (cervantes-core)

DefaultJwtValidator.validate(String rawToken) runs the following sequence:

Diagram
  1. Detection — a JWT has 3 dot-separated segments, a JWE has 5. Dispatch to the matching branch.

  2. Decryption (JWE only)JweDecryptor unwraps the ephemeral symmetric key (RSA-OAEP[-256]), decrypts the content (A256GCM), checks the authentication tag, checks the cty header (must be JWT for a nested token), and produces a JWS that re-enters the normal pipeline.

  3. ParsingJwtParser decodes the three segments through Base64.getUrlDecoder(), parses the header and payload JSON through Champollion → ParsedJwt(JsonObject header, JsonObject payload, byte[] signedContent, byte[] signature).

  4. Key selectionKeyResolver.resolve(header) returns the PublicKey to use. Three implementations in KeyResolvers: fromInlinePem, fromLocation (PEM-vs-JWKS auto-detection), and JwksKeyResolver for multi-key JWKS URLs/files.

  5. Signature verificationJwtSignatureVerifier instantiates a java.security.Signature according to the header’s alg, verifies signedContent against signature. For ECDSA, R‖S → DER transcoding through EcdsaSignatures (the JDK requires DER).

  6. Claim validationJwtClaimsValidator checks iss (strict equality with JwtConfig.expectedIssuer), aud (non-empty intersection with JwtConfig.expectedAudiences), exp (≥ now - skew), nbf (≤ now + skew), optionally iat (presence + max age via token.age).

  7. Principal constructionDefaultJsonWebToken wraps the validated payload. Immutable. getName() prioritises upn > preferred_username > sub. getGroups() extracts the groups claim (unmodifiable set).

The result is either a validated JsonWebToken or a typed JwtValidationException. No step is short-circuited — a failure at n interrupts the whole chain before n+1.

JAX-RS auth filter (cervantes-jaxrs)

JwtAuthenticationFilter is a ContainerRequestFilter @PreMatching @Priority(AUTHENTICATION).

// Excerpt from JwtAuthenticationFilter.filter(ContainerRequestContext)
String rawToken = extractToken(requestContext);   // Authorization or Cookie
if (rawToken == null) return;                      // anonymous — @RolesAllowed will decide

try {
    JsonWebToken jwt = validator.validate(rawToken);
    tokenContext.setToken(jwt);                    // publish for CDI
    SecurityContext previous = requestContext.getSecurityContext();
    boolean secure = previous != null && previous.isSecure();
    requestContext.setSecurityContext(new JwtSecurityContext(jwt, secure));
} catch (JwtValidationException e) {
    requestContext.abortWith(Response.status(Status.UNAUTHORIZED).build());
}

@PreMatching is required so that setSecurityContext can be called (JAX-RS §6.6). The AUTHENTICATION priority guarantees execution before authorisation filters.

JwtSecurityContext adapts SecurityContext:

  • getUserPrincipal() → the JsonWebToken (which is a Principal).

  • isUserInRole(role)jwt.getGroups().contains(role).

  • getAuthenticationScheme()"MP-JWT".

  • isSecure() → inherited from the previous context.

RolesAllowedDynamicFeature introspects JAX-RS methods at startup and registers RolesAllowedRequestFilter based on @RolesAllowed / @PermitAll / @DenyAll. Precedence: method > class. No hot reflection — the decision is static per endpoint, taken once.

CDI bridge: JsonWebTokenContext

The JAX-RS filter and the CDI producer do not know each other directly. They communicate through JsonWebTokenContext, a @RequestScoped bean:

@RequestScoped
public class JsonWebTokenContext {
    private JsonWebToken token;
    public void setToken(JsonWebToken t) { this.token = t; }
    public JsonWebToken getToken() { return token; }
}
  • Filter side: tokenContext.setToken(jwt) (at the start of the request).

  • Producer side: JsonWebTokenProducer.@Produces @RequestScoped JsonWebToken get(JsonWebTokenContext ctx) reads ctx.getToken() (or returns an anonymous DefaultJsonWebToken.anonymous() if absent).

  • @Claim side: the synthetic BCE also re-reads ctx.getToken() on every resolution.

@RequestScoped guarantees per-request isolation without ThreadLocal — which avoids pinning virtual threads.

Vauban BCE: CervantesClaimExtension

@Claim injection is resolved through a Vauban Build Compatible Extension.

  1. @Registration — Vauban calls the extension for each injection-point type carrying @Claim. The extension collects the list of types encountered (String, Long, Set<String>, Optional<String>, ClaimValue<String>, …).

  2. @Synthesis — Vauban calls the extension, which registers, for each collected type, a SyntheticBean qualified @Claim and @Dependent. Because @Claim has value and standard marked @Nonbinding, a single bean per type covers all injection sites — the claim name is read off the InjectionPoint at instantiation.

  3. ClaimSyntheticCreator — implements SyntheticBeanCreator<T>. On each resolution, it reads the InjectionPoint, extracts @Claim (value() or standard().name()), retrieves the current JsonWebToken through JsonWebTokenContext, and delegates to ClaimResolver.resolve(jwt, claimName, type) for typed conversion.

The pattern mirrors Ravel’s ConfigCdiExtension, which inspired the approach.

Thread-safe JWKS cache (JwksKeyResolver)

JwksKeyResolver must serve `PublicKey`s for the lifetime of the application, absorbing rotation. Constraints: no virtual-thread blocking, no thundering-herd of concurrent fetches, no error cascade if the issuer is unavailable.

Implementation:

Mechanism Role

AtomicReference<JwksSnapshot> (kidPublicKey)

Lock-free reads. Every request reads the current snapshot in O(1).

Non-fair ReentrantLock

Serialises refreshes — one thread fetches the JWK Set at a time. Others wait and re-read the snapshot.

Configurable TTL + Instant lastRefresh

Periodic refresh even without visible rotation — defence against compromised keys.

minRefreshInterval

Reactive refresh, bounded — an unknown kid triggers at most one refresh per window. Defence against an attacker forging random `kid`s.

Fallback snapshot

If the fetch fails (network, 5xx, parse error), keeps serving the previous snapshot. No 503 cascade.

JDK HttpClient + HttpRequest.timeout(…​)

I/O through java.net.http.HttpClient, virtual-thread-friendly. No synchronized around I/O.

The snapshot is immutable; the AtomicReference acts as the memory barrier (JMM happens-before).

Threading and ScopedValue

  1. No synchronized around I/O. JWKS fetching, PEM file reading and JSON parsing go through primitives non-blocking for the virtual-thread scheduler.

  2. No ThreadLocal. Per-request state sharing goes through @RequestScoped (CDI) or SecurityContext (JAX-RS), not ThreadLocal. Avoids virtual-thread pinning.

  3. ReentrantLock everywhere a lock is needed (JWKS refresh, internal registries), in line with the global Vidocq philosophy.

  • Each sub-module ships its own module-info.java. No Automatic-Module-Name fallback.

  • No setAccessible(true) in production. When a privileged view is needed (rarely), MethodHandles.privateLookupIn.

  • No dynamic java.lang.reflect.Proxy. @Claim beans are synthetic, created at CDI compile time by Vauban — not at runtime.

  • cervantes-tck stays outside the reactor (Model 4.0.0), to avoid blocking the compilation of the other modules with ShrinkWrap Maven Resolver 3.3 (a transitive constraint of the official TCK).

Unit tests of cervantes-core

A matter of pride: the entire engine is testable without HTTP, without CDI, with key pairs generated on the fly through KeyPairGenerator. The test suites:

  • DefaultJwtValidatorTest — end-to-end signature + claims.

  • JwtSignatureVerifierTest — each family (RSA, EC, PS, HS).

  • JwkParserTest — RSA and EC JWK parsing.

  • JwksKeyResolverTest — cache, refresh, rotation, fallback.

  • JweDecryptorTest — unwrapping + decryption.

  • JwtClaimsValidatorTest — iss/aud/exp/nbf/iat + clock-skew.

No Mockito — only hand-written doubles and generated key pairs.

Further reading

  • Concepts — JWT, claims, signature, JWK Set.

  • TCK — what the official suite validates.

  • Reference — annotations and mp.jwt.* keys.