JSON Web Key (JWK) set retrieval with rate-limiting, caching and retrial support

Applications that need to retrieve JWK sets to verify digital signatures of JWTs or for other purposes can benefit from a new Nimbus JOSE+JWT facility for sourcing keys.

The JWKSourceBuilder is the entry point for applications. It takes a JWK set source, such as a URL, and wraps with with various capabilities:

  • Rate limiting to guard against frequent, potentially costly, network calls. The rate limiting is smart to let through additional requests to handle potential key rotations at the source.
  • Caching with refresh-ahead. The refresh ahead will deliver uninterrupted operation under high-load, avoiding a potentially large number of threads blocking when the cache expires (and must be refreshed).
  • Retrial to work around transient network failures.
  • Outage tolerance to handle temporary network issues and endpoint downtime, potentially running into minutes or hours. Transparently caches the JWK set provided by the wrapped source, returning it in case the underlying source throws an unavailable exception.
  • Failover to one or more backup JWK set sources in case of an outage. The backup source can be a URL at an alternative data centre, a local file, or some other source.
  • Health status reporting to an event listener.

This facility is a contribution appearing in v9.28.

Default JWK source builder configuration

Creating a URL-based JWK source using the default builder configuration, which will set rate limiting and caching with refresh-ahead.

The rate limiting will be set to the default setting of 30 seconds between calls to the URL. The downloaded JWK set will be cached for 5 minutes. 30 seconds prior to the cache's expiration the JWK set will be refreshed from the URL on a separate dedicated thread.

import java.net.*;
import java.util.*;
import com.nimbusds.jose.jwk.*;
import com.nimbusds.jose.jwk.source.*;
import com.nimbusds.jose.proc.*;

// The public JWK set URL
URL url = new URL("https://example.com/jwks.json");

// Will select RSA keys marked for signature use only
JWKSelector selector = new JWKSelector(
    new JWKMatcher.Builder()
        .keyType(KeyType.RSA)
        .keyUse(KeyUse.SIGNATURE)
        .build());

// Some security context that may be required by the JOSE
// signature checking and JWT processing framework, may be
// null if not required
SecurityContext ctx = new SimpleSecurityContext();

// Create a new JWK source with rate limiting and refresh ahead
// caching, using sensible default settings
JWKSource<SecurityContext> jwkSource = JWKSourceBuilder.create(url)
    .build();

// Retrieve the matching JWKs from the source
List<JWK> jwks = jwkSource.get(selector, ctx);

Setting cache time-to-live and refresh timeout

To override the default cache TTL and refresh timeouts, which are set in milliseconds:

long ttl = 60*60*1000; // 1 hour
long refreshTimeout = 60*1000; // 1 minute
JWKSource<SecurityContext> jwkSource = JWKSourceBuilder.create(url)
    .cache(ttl, refreshTimeout)
    .build();

Adding retrial on network errors

To add retrial in case of transient network errors preventing the retrieval of the JWK set:

JWKSource<SecurityContext> jwkSource = JWKSourceBuilder.create(url)
    .retrying(true)
    .build();

Adding outage tolerance

To let your application continue running in case the remote JWK set endpoint goes down for a long time you can add an outage cache with a long time-to-live.

Example JWK source with retrial and a secondary outage cache:

long outageTTL = 4*60*60*1000; // 4 hours
JWKSource<SecurityContext> jwkSource = JWKSourceBuilder.create(url)
    .retrying(true)
    .outageTolerant(outageTTL)
    .build();

Adding fail-over JWK source

Another possible tactic is to configure a backup URL for the keys at a different data centre in case the primary goes down:

URL backupURL = new URL("https://backup.example.com/jwks.json");

JWKSource<SecurityContext> backupJWKSource = JWKSourceBuilder.create(backupURL)
    .build();

JWKSource<SecurityContext> jwkSource = JWKSourceBuilder.create(url)
    .retrying(true)
    .failover(backupJWKSource)
    .build();

If necessary multiple backup sources can be chained together.

Adding health status reporting

To add a health report listener to monitor status changes of the JWK source:

HealthReportListener<JWKSetSourceWithHealthStatusReporting<SecurityContext>, SecurityContext> listener =
    new HealthReportListener<JWKSetSourceWithHealthStatusReporting<SecurityContext>, SecurityContext>() {

    @Override
    public void notify(HealthReport<JWKSetSourceWithHealthStatusReporting<SecurityContext>, SecurityContext> healthReport) {

        if (HealthStatus.HEALTHY.equals(healthReport.getHealthStatus())) {
            return; // JWK source okay
        }

        System.out.println("Source health degraded: " + healthReport.getException());
        System.out.println("Timestamp: " + healthReport.getTimestamp());
    }
};

JWKSource<SecurityContext> jwkSource = JWKSourceBuilder.create(url)
    .healthReporting(listener)
    .build();

Stacking multiple capabilities

The JWK source can be built with multiple capabilities.

JWKSource<SecurityContext> jwkSource = JWKSourceBuilder.create(url)
    .retrying(true)
    .outageTolerant(true)
    .healthReporting(listener)
    .build();

If the configured capabilities are not compatible or their settings conflict the build method will throw an IllegalStateException with an informative message.

Disabling the default capabilities

The default rate limiting and caching capabilities can be disabled like this, leaving a bare bones JWK source:

// Strip the JWK source of the default rate-limiter and caching wrappers
JWKSource<SecurityContext> jwkSource = JWKSourceBuilder.create(url)
    .rateLimited(false)
    .cache(false)
    .build();

Adding logging to a wrapper

To add logging or other event handling to a capability wrapper the builder exposes extra EventListener arguments:

EventListener<RateLimitedJWKSetSource<SecurityContext>, SecurityContext> logger =
new EventListener<RateLimitedJWKSetSource<SecurityContext>, SecurityContext>() {

    @Override
    public void notify(Event<RateLimitedJWKSetSource<SecurityContext>, SecurityContext> event) {
        if (event instanceof RateLimitedJWKSetSource.RateLimitedEvent) {
            // Log event...
        }
    }
};

JWKSource<SecurityContext> jwkSource = JWKSourceBuilder.create(url)
    .rateLimited(
        JWKSourceBuilder.DEFAULT_RATE_LIMIT_MIN_INTERVAL,
        logger)
    .build();

The event listening interface is designed to enable the definition of new event classes in future and extend them with additional fields if necessary. The SecurityContext makes it possible to include application specific context necessary for the logging.

Handling retrieval of signed JWK sets

An application which deals with signed JWK sets, such can for instance occur in OpenID Connect Federation 1.0 deployments, should pass a custom ResourceRetriever to the create method of the JWKSourceBuilder:

ResourceRetriever signedJWKSetRetriever = new DefaultResourceRetriever() {

    @Override
    public Resource retrieveResource(URL url)
        throws IOException {

        Resource resource = super.retrieveResource(url);

        SignedJWT jwt;
        try {
            jwt = SignedJWT.parse(resource.getContent());
        } catch (ParseException e) {
            throw new IOException("Invalid JWT: " + e.getMessage(), e);
        }

        // Logic to verify JWT ...

        return new Resource(jwt.getJWTClaimsSet().toString(), "application/json");
    }
};

JWKSource<SecurityContext> jwkSource = JWKSourceBuilder.create(
        url,
        signedJWKSetRetriever)
    .build();