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:
-
Detection — a JWT has 3 dot-separated segments, a JWE has 5. Dispatch to the matching branch.
-
Decryption (JWE only) —
JweDecryptorunwraps the ephemeral symmetric key (RSA-OAEP[-256]), decrypts the content (A256GCM), checks the authentication tag, checks thectyheader (must beJWTfor a nested token), and produces a JWS that re-enters the normal pipeline. -
Parsing —
JwtParserdecodes the three segments throughBase64.getUrlDecoder(), parses the header and payload JSON through Champollion →ParsedJwt(JsonObject header, JsonObject payload, byte[] signedContent, byte[] signature). -
Key selection —
KeyResolver.resolve(header)returns thePublicKeyto use. Three implementations inKeyResolvers:fromInlinePem,fromLocation(PEM-vs-JWKS auto-detection), andJwksKeyResolverfor multi-key JWKS URLs/files. -
Signature verification —
JwtSignatureVerifierinstantiates ajava.security.Signatureaccording to the header’salg, verifiessignedContentagainstsignature. For ECDSA, R‖S → DER transcoding throughEcdsaSignatures(the JDK requires DER). -
Claim validation —
JwtClaimsValidatorchecksiss(strict equality withJwtConfig.expectedIssuer),aud(non-empty intersection withJwtConfig.expectedAudiences),exp(≥now - skew),nbf(≤now + skew), optionallyiat(presence + max age viatoken.age). -
Principal construction —
DefaultJsonWebTokenwraps the validated payload. Immutable.getName()prioritisesupn>preferred_username>sub.getGroups()extracts thegroupsclaim (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()→ theJsonWebToken(which is aPrincipal). -
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)readsctx.getToken()(or returns an anonymousDefaultJsonWebToken.anonymous()if absent). -
@Claimside: the synthetic BCE also re-readsctx.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.
-
@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>, …). -
@Synthesis— Vauban calls the extension, which registers, for each collected type, aSyntheticBeanqualified@Claimand@Dependent. Because@Claimhasvalueandstandardmarked@Nonbinding, a single bean per type covers all injection sites — the claim name is read off theInjectionPointat instantiation. -
ClaimSyntheticCreator— implementsSyntheticBeanCreator<T>. On each resolution, it reads theInjectionPoint, extracts@Claim(value()orstandard().name()), retrieves the currentJsonWebTokenthroughJsonWebTokenContext, and delegates toClaimResolver.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 |
|---|---|
|
Lock-free reads. Every request reads the current snapshot in |
Non-fair |
Serialises refreshes — one thread fetches the JWK Set at a time. Others wait and re-read the snapshot. |
Configurable TTL + |
Periodic refresh even without visible rotation — defence against compromised keys. |
|
Reactive refresh, bounded — an unknown |
Fallback snapshot |
If the fetch fails (network, 5xx, parse error), keeps serving the previous snapshot. No 503 cascade. |
JDK |
I/O through |
The snapshot is immutable; the AtomicReference acts as the memory barrier (JMM happens-before).
Threading and ScopedValue
-
No
synchronizedaround I/O. JWKS fetching, PEM file reading and JSON parsing go through primitives non-blocking for the virtual-thread scheduler. -
No
ThreadLocal. Per-request state sharing goes through@RequestScoped(CDI) orSecurityContext(JAX-RS), notThreadLocal. Avoids virtual-thread pinning. -
ReentrantLockeverywhere a lock is needed (JWKS refresh, internal registries), in line with the global Vidocq philosophy.
AOT / jlink compatibility
-
Each sub-module ships its own
module-info.java. NoAutomatic-Module-Namefallback. -
No
setAccessible(true)in production. When a privileged view is needed (rarely),MethodHandles.privateLookupIn. -
No dynamic
java.lang.reflect.Proxy.@Claimbeans are synthetic, created at CDI compile time by Vauban — not at runtime. -
cervantes-tckstays 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.