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 claims, byte[] signingInput, byte[] signature, String rawToken). -
Key selection —
KeyResolver.resolve(String kid, SignatureAlgorithm algorithm)returns anOptional<PublicKey>(empty when thekidcannot be resolved). Three implementations:KeyResolvers.fromInlinePem,KeyResolvers.fromLocation(PEM-vs-JWKS auto-detection), andJwksKeyResolverfor multi-key JWKS URLs/files. -
Signature verification —
JwtSignatureVerifierinstantiates ajava.security.Signatureaccording to the header’salg, verifiessigningInputagainstsignature. For ECDSA, R‖S → DER transcoding throughEcdsaSignatures(the JDK requires DER). -
Claim validation —
JwtClaimsValidatorchecksiss(strict equality withJwtConfig.issuer()),aud(non-empty intersection withJwtConfig.audiences()),exp(rejected onceexp + skew < now),nbf(rejected whilenbf - skew > now), andiat(rejected if later thanexp; maximum age viatoken.age, applied only when the claim is present). -
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;
/** 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)readsctx.current()and falls back to the anonymousDefaultJsonWebToken.anonymous()when it is empty. -
@Claimside: the synthetic BCE also re-readsctx.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.
-
@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-tckjoins the reactor only under thetckMaven profile (a regular child ofcervantes-parent): a plainmvn installnever 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.