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. Parsing — JwtParser decodes the three segments through Base64.getUrlDecoder(), parses the header and payload JSON through Champollion → ParsedJwt(JsonObject header, JsonObject claims, byte[] signingInput, byte[] signature, String rawToken).

  4. Key selection — KeyResolver.resolve(String kid, SignatureAlgorithm algorithm) returns an Optional<PublicKey> (empty when the kid cannot be resolved). Three implementations: KeyResolvers.fromInlinePem, KeyResolvers.fromLocation (PEM-vs-JWKS auto-detection), and JwksKeyResolver for multi-key JWKS URLs/files.

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

  6. Claim validation — JwtClaimsValidator checks iss (strict equality with JwtConfig.issuer()), aud (non-empty intersection with JwtConfig.audiences()), exp (rejected once exp + skew < now), nbf (rejected while nbf - skew > now), and iat (rejected if later than exp; maximum age via token.age, applied only when the claim is present).

  7. Principal construction — DefaultJsonWebToken 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;

    /** Stores the validated token for the request (called by the authentication filter). */
    public void setToken(JsonWebToken token) { this.token = token; }

    /** @return the token for the current request if it was validated, otherwise empty (anonymous request). */
    public Optional<JsonWebToken> current() { return Optional.ofNullable(token); }

    /** Resets the context (end of request). */
    public void clear() { this.token = null; }
}
  • Filter side: tokenContext.setToken(jwt) (at the start of the request); clear() resets the context.

  • Producer side: JsonWebTokenProducer.@Produces @RequestScoped JsonWebToken get(JsonWebTokenContext ctx) reads ctx.current() and falls back to the anonymous DefaultJsonWebToken.anonymous() when it is empty.

  • @Claim side: the synthetic BCE also re-reads ctx.current() 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<Snapshot> — a private record Snapshot(Jwks jwks, Instant fetchedAt)

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 + Snapshot.fetchedAt

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 joins the reactor only under the tck Maven profile (a regular child of cervantes-parent): a plain mvn install never builds nor downloads it, which decouples the runtime build from the official TCK stack.

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 verification (RS and ES families) + claim validation (iss/aud/exp/nbf/iat + clock-skew).

  • JwkParserTest — RSA and EC JWK parsing.

  • JwksKeyResolverTest — cache, refresh, rotation, fallback.

  • JweDecryptorTest — key unwrapping + content decryption.

  • TestJwts, TestJwks, TestJwe — helpers that forge tokens, JWK Sets and JWEs on the fly.

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.