|  | @@ -6,6 +6,7 @@
 | 
	
		
			
				|  |  |   */
 | 
	
		
			
				|  |  |  package org.elasticsearch.xpack.security.authc.jwt;
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | +import com.nimbusds.jose.JWSHeader;
 | 
	
		
			
				|  |  |  import com.nimbusds.jose.jwk.JWK;
 | 
	
		
			
				|  |  |  import com.nimbusds.jose.jwk.OctetSequenceKey;
 | 
	
		
			
				|  |  |  import com.nimbusds.jwt.JWTClaimsSet;
 | 
	
	
		
			
				|  | @@ -15,12 +16,15 @@ import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
 | 
	
		
			
				|  |  |  import org.apache.logging.log4j.LogManager;
 | 
	
		
			
				|  |  |  import org.apache.logging.log4j.Logger;
 | 
	
		
			
				|  |  |  import org.elasticsearch.action.ActionListener;
 | 
	
		
			
				|  |  | +import org.elasticsearch.action.support.PlainActionFuture;
 | 
	
		
			
				|  |  |  import org.elasticsearch.common.Strings;
 | 
	
		
			
				|  |  | +import org.elasticsearch.common.bytes.BytesArray;
 | 
	
		
			
				|  |  |  import org.elasticsearch.common.cache.Cache;
 | 
	
		
			
				|  |  |  import org.elasticsearch.common.cache.CacheBuilder;
 | 
	
		
			
				|  |  |  import org.elasticsearch.common.hash.MessageDigests;
 | 
	
		
			
				|  |  |  import org.elasticsearch.common.settings.SecureString;
 | 
	
		
			
				|  |  |  import org.elasticsearch.common.settings.SettingsException;
 | 
	
		
			
				|  |  | +import org.elasticsearch.common.util.concurrent.ListenableFuture;
 | 
	
		
			
				|  |  |  import org.elasticsearch.common.util.concurrent.ReleasableLock;
 | 
	
		
			
				|  |  |  import org.elasticsearch.common.util.concurrent.ThreadContext;
 | 
	
		
			
				|  |  |  import org.elasticsearch.core.Releasable;
 | 
	
	
		
			
				|  | @@ -37,18 +41,19 @@ import org.elasticsearch.xpack.core.security.authc.support.UserRoleMapper;
 | 
	
		
			
				|  |  |  import org.elasticsearch.xpack.core.security.support.CacheIteratorHelper;
 | 
	
		
			
				|  |  |  import org.elasticsearch.xpack.core.security.user.User;
 | 
	
		
			
				|  |  |  import org.elasticsearch.xpack.core.ssl.SSLService;
 | 
	
		
			
				|  |  | -import org.elasticsearch.xpack.security.authc.BytesKey;
 | 
	
		
			
				|  |  |  import org.elasticsearch.xpack.security.authc.support.ClaimParser;
 | 
	
		
			
				|  |  |  import org.elasticsearch.xpack.security.authc.support.DelegatedAuthorizationSupport;
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  import java.io.IOException;
 | 
	
		
			
				|  |  |  import java.net.URI;
 | 
	
		
			
				|  |  |  import java.nio.charset.StandardCharsets;
 | 
	
		
			
				|  |  | -import java.security.MessageDigest;
 | 
	
		
			
				|  |  | +import java.util.Arrays;
 | 
	
		
			
				|  |  |  import java.util.Collections;
 | 
	
		
			
				|  |  |  import java.util.Date;
 | 
	
		
			
				|  |  |  import java.util.List;
 | 
	
		
			
				|  |  |  import java.util.Map;
 | 
	
		
			
				|  |  | +import java.util.Objects;
 | 
	
		
			
				|  |  | +import java.util.concurrent.atomic.AtomicReference;
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  import static java.lang.String.join;
 | 
	
		
			
				|  |  |  import static org.elasticsearch.core.Strings.format;
 | 
	
	
		
			
				|  | @@ -60,11 +65,34 @@ import static org.elasticsearch.core.Strings.format;
 | 
	
		
			
				|  |  |  public class JwtRealm extends Realm implements CachingRealm, Releasable {
 | 
	
		
			
				|  |  |      private static final Logger LOGGER = LogManager.getLogger(JwtRealm.class);
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -    record ExpiringUser(User user, Date exp) {}
 | 
	
		
			
				|  |  | +    // Cached authenticated users, and adjusted JWT expiration date (=exp+skew) for checking if the JWT expired before the cache entry
 | 
	
		
			
				|  |  | +    record ExpiringUser(User user, Date exp) {
 | 
	
		
			
				|  |  | +        ExpiringUser {
 | 
	
		
			
				|  |  | +            Objects.requireNonNull(user, "User must not be null");
 | 
	
		
			
				|  |  | +            Objects.requireNonNull(exp, "Expiration date must not be null");
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | +    // Original PKC/HMAC JWKSet or HMAC JWK content (for comparison during refresh), and filtered JWKs and Algs
 | 
	
		
			
				|  |  | +    record ContentAndJwksAlgs(byte[] sha256, JwksAlgs jwksAlgs) {
 | 
	
		
			
				|  |  | +        ContentAndJwksAlgs {
 | 
	
		
			
				|  |  | +            Objects.requireNonNull(jwksAlgs, "Filters JWKs and Algs must not be null");
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        boolean isEmpty() {
 | 
	
		
			
				|  |  | +            return ((this.sha256 == null) || this.sha256.length == 0) && this.jwksAlgs.isEmpty();
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    // Filtered JWKs and Algs
 | 
	
		
			
				|  |  |      record JwksAlgs(List<JWK> jwks, List<String> algs) {
 | 
	
		
			
				|  |  | +        JwksAlgs {
 | 
	
		
			
				|  |  | +            Objects.requireNonNull(jwks, "JWKs must not be null");
 | 
	
		
			
				|  |  | +            Objects.requireNonNull(algs, "Algs must not be null");
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  |          boolean isEmpty() {
 | 
	
		
			
				|  |  | -            return jwks.isEmpty() && algs.isEmpty();
 | 
	
		
			
				|  |  | +            return this.jwks.isEmpty() && this.algs.isEmpty();
 | 
	
		
			
				|  |  |          }
 | 
	
		
			
				|  |  |      }
 | 
	
		
			
				|  |  |  
 | 
	
	
		
			
				|  | @@ -78,9 +106,11 @@ public class JwtRealm extends Realm implements CachingRealm, Releasable {
 | 
	
		
			
				|  |  |      final String allowedIssuer;
 | 
	
		
			
				|  |  |      final List<String> allowedAudiences;
 | 
	
		
			
				|  |  |      final String jwkSetPath;
 | 
	
		
			
				|  |  | -    final CloseableHttpAsyncClient httpClient;
 | 
	
		
			
				|  |  | -    final JwtRealm.JwksAlgs jwksAlgsHmac;
 | 
	
		
			
				|  |  | -    final JwtRealm.JwksAlgs jwksAlgsPkc;
 | 
	
		
			
				|  |  | +    final boolean isConfiguredJwkSetPkc;
 | 
	
		
			
				|  |  | +    final boolean isConfiguredJwkSetHmac;
 | 
	
		
			
				|  |  | +    final boolean isConfiguredJwkOidcHmac;
 | 
	
		
			
				|  |  | +    private final CloseableHttpAsyncClient httpClient;
 | 
	
		
			
				|  |  | +    final JwkSetLoader jwkSetLoader;
 | 
	
		
			
				|  |  |      final TimeValue allowedClockSkew;
 | 
	
		
			
				|  |  |      final Boolean populateUserMetadata;
 | 
	
		
			
				|  |  |      final ClaimParser claimParserPrincipal;
 | 
	
	
		
			
				|  | @@ -90,9 +120,14 @@ public class JwtRealm extends Realm implements CachingRealm, Releasable {
 | 
	
		
			
				|  |  |      final ClaimParser claimParserName;
 | 
	
		
			
				|  |  |      final JwtRealmSettings.ClientAuthenticationType clientAuthenticationType;
 | 
	
		
			
				|  |  |      final SecureString clientAuthenticationSharedSecret;
 | 
	
		
			
				|  |  | -    final Cache<BytesKey, ExpiringUser> jwtCache;
 | 
	
		
			
				|  |  | -    final CacheIteratorHelper<BytesKey, ExpiringUser> jwtCacheHelper;
 | 
	
		
			
				|  |  | +    final Cache<BytesArray, ExpiringUser> jwtCache;
 | 
	
		
			
				|  |  | +    final CacheIteratorHelper<BytesArray, ExpiringUser> jwtCacheHelper;
 | 
	
		
			
				|  |  | +    final List<String> allowedJwksAlgsPkc;
 | 
	
		
			
				|  |  | +    final List<String> allowedJwksAlgsHmac;
 | 
	
		
			
				|  |  |      DelegatedAuthorizationSupport delegatedAuthorizationSupport = null;
 | 
	
		
			
				|  |  | +    ContentAndJwksAlgs contentAndJwksAlgsPkc;
 | 
	
		
			
				|  |  | +    ContentAndJwksAlgs contentAndJwksAlgsHmac;
 | 
	
		
			
				|  |  | +    final URI jwkSetPathUri;
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |      JwtRealm(
 | 
	
		
			
				|  |  |          final RealmConfig realmConfig,
 | 
	
	
		
			
				|  | @@ -127,9 +162,17 @@ public class JwtRealm extends Realm implements CachingRealm, Releasable {
 | 
	
		
			
				|  |  |              this.clientAuthenticationSharedSecret
 | 
	
		
			
				|  |  |          );
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -        if (config.hasSetting(JwtRealmSettings.HMAC_KEY) == false
 | 
	
		
			
				|  |  | -            && config.hasSetting(JwtRealmSettings.HMAC_JWKSET) == false
 | 
	
		
			
				|  |  | -            && config.hasSetting(JwtRealmSettings.PKC_JWKSET_PATH) == false) {
 | 
	
		
			
				|  |  | +        // Split configured signature algorithms by PKC and HMAC. Useful during validation, error logging, and JWK vs Alg filtering.
 | 
	
		
			
				|  |  | +        final List<String> algs = super.config.getSetting(JwtRealmSettings.ALLOWED_SIGNATURE_ALGORITHMS);
 | 
	
		
			
				|  |  | +        this.allowedJwksAlgsHmac = algs.stream().filter(JwtRealmSettings.SUPPORTED_SIGNATURE_ALGORITHMS_HMAC::contains).toList();
 | 
	
		
			
				|  |  | +        this.allowedJwksAlgsPkc = algs.stream().filter(JwtRealmSettings.SUPPORTED_SIGNATURE_ALGORITHMS_PKC::contains).toList();
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        // PKC JWKSet can be URL, file, or not set; only initialize HTTP client if PKC JWKSet is a URL.
 | 
	
		
			
				|  |  | +        this.jwkSetPath = super.config.getSetting(JwtRealmSettings.PKC_JWKSET_PATH);
 | 
	
		
			
				|  |  | +        this.isConfiguredJwkSetPkc = Strings.hasText(this.jwkSetPath);
 | 
	
		
			
				|  |  | +        this.isConfiguredJwkSetHmac = Strings.hasText(super.config.getSetting(JwtRealmSettings.HMAC_JWKSET));
 | 
	
		
			
				|  |  | +        this.isConfiguredJwkOidcHmac = Strings.hasText(super.config.getSetting(JwtRealmSettings.HMAC_KEY));
 | 
	
		
			
				|  |  | +        if (this.isConfiguredJwkSetPkc == false && this.isConfiguredJwkSetHmac == false && this.isConfiguredJwkOidcHmac == false) {
 | 
	
		
			
				|  |  |              throw new SettingsException(
 | 
	
		
			
				|  |  |                  "At least one of ["
 | 
	
		
			
				|  |  |                      + RealmSettings.getFullSettingKey(realmConfig, JwtRealmSettings.HMAC_KEY)
 | 
	
	
		
			
				|  | @@ -141,44 +184,39 @@ public class JwtRealm extends Realm implements CachingRealm, Releasable {
 | 
	
		
			
				|  |  |              );
 | 
	
		
			
				|  |  |          }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -        // PKC JWKSet can be URL, file, or not set; only initialize HTTP client if PKC JWKSet is a URL.
 | 
	
		
			
				|  |  | -        this.jwkSetPath = super.config.getSetting(JwtRealmSettings.PKC_JWKSET_PATH);
 | 
	
		
			
				|  |  | -        if (Strings.hasText(this.jwkSetPath)) {
 | 
	
		
			
				|  |  | -            final URI jwkSetPathPkcUri = JwtUtil.parseHttpsUri(this.jwkSetPath);
 | 
	
		
			
				|  |  | -            if (jwkSetPathPkcUri == null) {
 | 
	
		
			
				|  |  | -                this.httpClient = null; // local file means no HTTP client
 | 
	
		
			
				|  |  | +        if (this.isConfiguredJwkSetPkc) {
 | 
	
		
			
				|  |  | +            final URI jwkSetPathUri = JwtUtil.parseHttpsUri(jwkSetPath);
 | 
	
		
			
				|  |  | +            if (jwkSetPathUri == null) {
 | 
	
		
			
				|  |  | +                this.jwkSetPathUri = null; // local file path
 | 
	
		
			
				|  |  | +                this.httpClient = null;
 | 
	
		
			
				|  |  |              } else {
 | 
	
		
			
				|  |  | -                this.httpClient = JwtUtil.createHttpClient(super.config, sslService);
 | 
	
		
			
				|  |  | +                this.jwkSetPathUri = jwkSetPathUri; // HTTPS URL
 | 
	
		
			
				|  |  | +                this.httpClient = JwtUtil.createHttpClient(this.config, sslService);
 | 
	
		
			
				|  |  |              }
 | 
	
		
			
				|  |  | +            this.jwkSetLoader = new JwkSetLoader(); // PKC JWKSet loader for HTTPS URL or local file path
 | 
	
		
			
				|  |  |          } else {
 | 
	
		
			
				|  |  | -            this.httpClient = null; // no setting means no HTTP client
 | 
	
		
			
				|  |  | +            this.jwkSetPathUri = null; // not configured
 | 
	
		
			
				|  |  | +            this.httpClient = null;
 | 
	
		
			
				|  |  | +            this.jwkSetLoader = null;
 | 
	
		
			
				|  |  |          }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -        // If HTTPS client was created in JWT realm, any exception after that point requires closing it to avoid a thread pool leak
 | 
	
		
			
				|  |  | +        // Any exception during loading requires closing JwkSetLoader's HTTP client to avoid a thread pool leak
 | 
	
		
			
				|  |  |          try {
 | 
	
		
			
				|  |  | -            this.jwksAlgsHmac = this.parseJwksAlgsHmac();
 | 
	
		
			
				|  |  | -            this.jwksAlgsPkc = this.parseJwksAlgsPkc();
 | 
	
		
			
				|  |  | +            this.contentAndJwksAlgsHmac = this.parseJwksAlgsHmac();
 | 
	
		
			
				|  |  | +            this.contentAndJwksAlgsPkc = this.parseJwksAlgsPkc();
 | 
	
		
			
				|  |  |              this.verifyAnyAvailableJwkAndAlgPair();
 | 
	
		
			
				|  |  |          } catch (Throwable t) {
 | 
	
		
			
				|  |  | +            // ASSUME: Tests or startup only. Catch and rethrow Throwable here, in case some code throws an uncaught RuntimeException.
 | 
	
		
			
				|  |  |              this.close();
 | 
	
		
			
				|  |  |              throw t;
 | 
	
		
			
				|  |  |          }
 | 
	
		
			
				|  |  |      }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -    private Cache<BytesKey, ExpiringUser> buildJwtCache() {
 | 
	
		
			
				|  |  | -        final TimeValue jwtCacheTtl = super.config.getSetting(JwtRealmSettings.JWT_CACHE_TTL);
 | 
	
		
			
				|  |  | -        final int jwtCacheSize = super.config.getSetting(JwtRealmSettings.JWT_CACHE_SIZE);
 | 
	
		
			
				|  |  | -        if ((jwtCacheTtl.getNanos() > 0) && (jwtCacheSize > 0)) {
 | 
	
		
			
				|  |  | -            return CacheBuilder.<BytesKey, ExpiringUser>builder().setExpireAfterWrite(jwtCacheTtl).setMaximumWeight(jwtCacheSize).build();
 | 
	
		
			
				|  |  | -        }
 | 
	
		
			
				|  |  | -        return null;
 | 
	
		
			
				|  |  | -    }
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | -    // must call parseAlgsAndJwksHmac() before parseAlgsAndJwksPkc()
 | 
	
		
			
				|  |  | -    private JwtRealm.JwksAlgs parseJwksAlgsHmac() {
 | 
	
		
			
				|  |  | +    private ContentAndJwksAlgs parseJwksAlgsHmac() {
 | 
	
		
			
				|  |  |          final JwtRealm.JwksAlgs jwksAlgsHmac;
 | 
	
		
			
				|  |  |          final SecureString hmacJwkSetContents = super.config.getSetting(JwtRealmSettings.HMAC_JWKSET);
 | 
	
		
			
				|  |  |          final SecureString hmacKeyContents = super.config.getSetting(JwtRealmSettings.HMAC_KEY);
 | 
	
		
			
				|  |  | +        byte[] hmacStringContentsSha256 = null;
 | 
	
		
			
				|  |  |          if (Strings.hasText(hmacJwkSetContents) && Strings.hasText(hmacKeyContents)) {
 | 
	
		
			
				|  |  |              // HMAC Key vs HMAC JWKSet settings must be mutually exclusive
 | 
	
		
			
				|  |  |              throw new SettingsException(
 | 
	
	
		
			
				|  | @@ -195,68 +233,52 @@ public class JwtRealm extends Realm implements CachingRealm, Releasable {
 | 
	
		
			
				|  |  |              // At this point, one-and-only-one of the HMAC Key or HMAC JWKSet settings are set
 | 
	
		
			
				|  |  |              List<JWK> jwksHmac;
 | 
	
		
			
				|  |  |              if (Strings.hasText(hmacJwkSetContents)) {
 | 
	
		
			
				|  |  | +                hmacStringContentsSha256 = JwtUtil.sha256(hmacJwkSetContents.toString());
 | 
	
		
			
				|  |  |                  jwksHmac = JwkValidateUtil.loadJwksFromJwkSetString(
 | 
	
		
			
				|  |  |                      RealmSettings.getFullSettingKey(super.config, JwtRealmSettings.HMAC_JWKSET),
 | 
	
		
			
				|  |  |                      hmacJwkSetContents.toString()
 | 
	
		
			
				|  |  |                  );
 | 
	
		
			
				|  |  |              } else {
 | 
	
		
			
				|  |  |                  final OctetSequenceKey hmacKey = JwkValidateUtil.loadHmacJwkFromJwkString(
 | 
	
		
			
				|  |  | -                    RealmSettings.getFullSettingKey(super.config, JwtRealmSettings.HMAC_JWKSET),
 | 
	
		
			
				|  |  | +                    RealmSettings.getFullSettingKey(super.config, JwtRealmSettings.HMAC_KEY),
 | 
	
		
			
				|  |  |                      hmacKeyContents
 | 
	
		
			
				|  |  |                  );
 | 
	
		
			
				|  |  | +                assert hmacKey != null : "Null HMAC key should not happen here";
 | 
	
		
			
				|  |  |                  jwksHmac = List.of(hmacKey);
 | 
	
		
			
				|  |  | +                hmacStringContentsSha256 = JwtUtil.sha256(hmacKeyContents.toString());
 | 
	
		
			
				|  |  |              }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  |              // Filter JWK(s) vs signature algorithms. Only keep JWKs with a matching alg. Only keep algs with a matching JWK.
 | 
	
		
			
				|  |  | -            final List<String> algs = super.config.getSetting(JwtRealmSettings.ALLOWED_SIGNATURE_ALGORITHMS);
 | 
	
		
			
				|  |  | -            final List<String> algsHmac = algs.stream().filter(JwtRealmSettings.SUPPORTED_SIGNATURE_ALGORITHMS_HMAC::contains).toList();
 | 
	
		
			
				|  |  | -            jwksAlgsHmac = JwkValidateUtil.filterJwksAndAlgorithms(jwksHmac, algsHmac);
 | 
	
		
			
				|  |  | +            jwksAlgsHmac = JwkValidateUtil.filterJwksAndAlgorithms(jwksHmac, this.allowedJwksAlgsHmac);
 | 
	
		
			
				|  |  |          }
 | 
	
		
			
				|  |  |          LOGGER.info("Usable HMAC: JWKs [{}]. Algorithms [{}].", jwksAlgsHmac.jwks.size(), String.join(",", jwksAlgsHmac.algs()));
 | 
	
		
			
				|  |  | -        return jwksAlgsHmac;
 | 
	
		
			
				|  |  | +        return new ContentAndJwksAlgs(hmacStringContentsSha256, jwksAlgsHmac);
 | 
	
		
			
				|  |  |      }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -    private JwtRealm.JwksAlgs parseJwksAlgsPkc() {
 | 
	
		
			
				|  |  | -        final JwtRealm.JwksAlgs jwksAlgsPkc;
 | 
	
		
			
				|  |  | -        if (Strings.hasText(this.jwkSetPath) == false) {
 | 
	
		
			
				|  |  | -            jwksAlgsPkc = new JwtRealm.JwksAlgs(Collections.emptyList(), Collections.emptyList());
 | 
	
		
			
				|  |  | +    private ContentAndJwksAlgs parseJwksAlgsPkc() {
 | 
	
		
			
				|  |  | +        if (this.isConfiguredJwkSetPkc == false) {
 | 
	
		
			
				|  |  | +            return new ContentAndJwksAlgs(null, new JwksAlgs(Collections.emptyList(), Collections.emptyList()));
 | 
	
		
			
				|  |  |          } else {
 | 
	
		
			
				|  |  | -            // PKC JWKSet get contents from local file or remote HTTPS URL
 | 
	
		
			
				|  |  | -            final byte[] jwkSetContentBytesPkc;
 | 
	
		
			
				|  |  | -            if (this.httpClient == null) {
 | 
	
		
			
				|  |  | -                jwkSetContentBytesPkc = JwtUtil.readFileContents(
 | 
	
		
			
				|  |  | -                    RealmSettings.getFullSettingKey(super.config, JwtRealmSettings.PKC_JWKSET_PATH),
 | 
	
		
			
				|  |  | -                    this.jwkSetPath,
 | 
	
		
			
				|  |  | -                    super.config.env()
 | 
	
		
			
				|  |  | -                );
 | 
	
		
			
				|  |  | -            } else {
 | 
	
		
			
				|  |  | -                final URI jwkSetPathPkcUri = JwtUtil.parseHttpsUri(this.jwkSetPath);
 | 
	
		
			
				|  |  | -                jwkSetContentBytesPkc = JwtUtil.readUriContents(
 | 
	
		
			
				|  |  | -                    RealmSettings.getFullSettingKey(super.config, JwtRealmSettings.PKC_JWKSET_PATH),
 | 
	
		
			
				|  |  | -                    jwkSetPathPkcUri,
 | 
	
		
			
				|  |  | -                    this.httpClient
 | 
	
		
			
				|  |  | -                );
 | 
	
		
			
				|  |  | -            }
 | 
	
		
			
				|  |  | -            final String jwkSetContentsPkc = new String(jwkSetContentBytesPkc, StandardCharsets.UTF_8);
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | -            // PKC JWKSet parse contents
 | 
	
		
			
				|  |  | -            final List<JWK> jwksPkc = JwkValidateUtil.loadJwksFromJwkSetString(
 | 
	
		
			
				|  |  | -                RealmSettings.getFullSettingKey(super.config, JwtRealmSettings.PKC_JWKSET_PATH),
 | 
	
		
			
				|  |  | -                jwkSetContentsPkc
 | 
	
		
			
				|  |  | -            );
 | 
	
		
			
				|  |  | +            // ASSUME: Blocking read operations are OK during startup
 | 
	
		
			
				|  |  | +            final PlainActionFuture<ContentAndJwksAlgs> future = new PlainActionFuture<>();
 | 
	
		
			
				|  |  | +            this.jwkSetLoader.load(future);
 | 
	
		
			
				|  |  | +            return future.actionGet();
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -            // PKC JWKSet filter contents
 | 
	
		
			
				|  |  | -            final List<String> algs = super.config.getSetting(JwtRealmSettings.ALLOWED_SIGNATURE_ALGORITHMS);
 | 
	
		
			
				|  |  | -            final List<String> algsPkc = algs.stream().filter(JwtRealmSettings.SUPPORTED_SIGNATURE_ALGORITHMS_PKC::contains).toList();
 | 
	
		
			
				|  |  | -            jwksAlgsPkc = JwkValidateUtil.filterJwksAndAlgorithms(jwksPkc, algsPkc);
 | 
	
		
			
				|  |  | +    private Cache<BytesArray, ExpiringUser> buildJwtCache() {
 | 
	
		
			
				|  |  | +        final TimeValue jwtCacheTtl = super.config.getSetting(JwtRealmSettings.JWT_CACHE_TTL);
 | 
	
		
			
				|  |  | +        final int jwtCacheSize = super.config.getSetting(JwtRealmSettings.JWT_CACHE_SIZE);
 | 
	
		
			
				|  |  | +        if ((jwtCacheTtl.getNanos() > 0) && (jwtCacheSize > 0)) {
 | 
	
		
			
				|  |  | +            return CacheBuilder.<BytesArray, ExpiringUser>builder().setExpireAfterWrite(jwtCacheTtl).setMaximumWeight(jwtCacheSize).build();
 | 
	
		
			
				|  |  |          }
 | 
	
		
			
				|  |  | -        LOGGER.info("Usable PKC: JWKs [{}]. Algorithms [{}].", jwksAlgsPkc.jwks().size(), String.join(",", jwksAlgsPkc.algs()));
 | 
	
		
			
				|  |  | -        return jwksAlgsPkc;
 | 
	
		
			
				|  |  | +        return null;
 | 
	
		
			
				|  |  |      }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |      private void verifyAnyAvailableJwkAndAlgPair() {
 | 
	
		
			
				|  |  | -        assert this.jwksAlgsHmac != null : "HMAC not initialized";
 | 
	
		
			
				|  |  | -        assert this.jwksAlgsPkc != null : "PKC not initialized";
 | 
	
		
			
				|  |  | -        if (this.jwksAlgsHmac.isEmpty() && this.jwksAlgsPkc.isEmpty()) {
 | 
	
		
			
				|  |  | +        assert this.contentAndJwksAlgsHmac != null : "HMAC not initialized";
 | 
	
		
			
				|  |  | +        assert this.contentAndJwksAlgsPkc != null : "PKC not initialized";
 | 
	
		
			
				|  |  | +        if (this.contentAndJwksAlgsHmac.jwksAlgs.isEmpty() && this.contentAndJwksAlgsPkc.jwksAlgs.isEmpty()) {
 | 
	
		
			
				|  |  |              final String msg = "No available JWK and algorithm for HMAC or PKC. Realm authentication expected to fail until this is fixed.";
 | 
	
		
			
				|  |  |              throw new SettingsException(msg);
 | 
	
		
			
				|  |  |          }
 | 
	
	
		
			
				|  | @@ -289,13 +311,31 @@ public class JwtRealm extends Realm implements CachingRealm, Releasable {
 | 
	
		
			
				|  |  |       */
 | 
	
		
			
				|  |  |      @Override
 | 
	
		
			
				|  |  |      public void close() {
 | 
	
		
			
				|  |  | -        if (this.jwtCache != null) {
 | 
	
		
			
				|  |  | +        this.invalidateJwtCache();
 | 
	
		
			
				|  |  | +        this.closeHttpClient();
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    /**
 | 
	
		
			
				|  |  | +     * Clean up JWT cache (if enabled).
 | 
	
		
			
				|  |  | +     */
 | 
	
		
			
				|  |  | +    private void invalidateJwtCache() {
 | 
	
		
			
				|  |  | +        if ((this.jwtCache != null) && (this.jwtCacheHelper != null)) {
 | 
	
		
			
				|  |  |              try {
 | 
	
		
			
				|  |  | -                this.jwtCache.invalidateAll();
 | 
	
		
			
				|  |  | +                LOGGER.trace("Invalidating JWT cache for realm [{}]", super.name());
 | 
	
		
			
				|  |  | +                try (ReleasableLock ignored = this.jwtCacheHelper.acquireUpdateLock()) {
 | 
	
		
			
				|  |  | +                    this.jwtCache.invalidateAll();
 | 
	
		
			
				|  |  | +                }
 | 
	
		
			
				|  |  | +                LOGGER.debug("Invalidated JWT cache for realm [{}]", super.name());
 | 
	
		
			
				|  |  |              } catch (Exception e) {
 | 
	
		
			
				|  |  |                  LOGGER.warn("Exception invalidating JWT cache for realm [" + super.name() + "]", e);
 | 
	
		
			
				|  |  |              }
 | 
	
		
			
				|  |  |          }
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    /**
 | 
	
		
			
				|  |  | +     * Clean up HTTPS client cache (if enabled).
 | 
	
		
			
				|  |  | +     */
 | 
	
		
			
				|  |  | +    private void closeHttpClient() {
 | 
	
		
			
				|  |  |          if (this.httpClient != null) {
 | 
	
		
			
				|  |  |              try {
 | 
	
		
			
				|  |  |                  this.httpClient.close();
 | 
	
	
		
			
				|  | @@ -314,21 +354,17 @@ public class JwtRealm extends Realm implements CachingRealm, Releasable {
 | 
	
		
			
				|  |  |      @Override
 | 
	
		
			
				|  |  |      public void expire(final String username) {
 | 
	
		
			
				|  |  |          this.ensureInitialized();
 | 
	
		
			
				|  |  | -        LOGGER.trace("Expiring JWT cache entries for realm [" + super.name() + "] principal=[" + username + "]");
 | 
	
		
			
				|  |  |          if (this.jwtCacheHelper != null) {
 | 
	
		
			
				|  |  | +            LOGGER.trace("Expiring JWT cache entries for realm [{}] principal=[{}]", super.name(), username);
 | 
	
		
			
				|  |  |              this.jwtCacheHelper.removeValuesIf(expiringUser -> expiringUser.user.principal().equals(username));
 | 
	
		
			
				|  |  | +            LOGGER.trace("Expired JWT cache entries for realm [{}] principal=[{}]", super.name(), username);
 | 
	
		
			
				|  |  |          }
 | 
	
		
			
				|  |  |      }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |      @Override
 | 
	
		
			
				|  |  |      public void expireAll() {
 | 
	
		
			
				|  |  |          this.ensureInitialized();
 | 
	
		
			
				|  |  | -        if ((this.jwtCache != null) && (this.jwtCacheHelper != null)) {
 | 
	
		
			
				|  |  | -            LOGGER.trace("Invalidating JWT cache for realm [" + super.name() + "]");
 | 
	
		
			
				|  |  | -            try (ReleasableLock ignored = this.jwtCacheHelper.acquireUpdateLock()) {
 | 
	
		
			
				|  |  | -                this.jwtCache.invalidateAll();
 | 
	
		
			
				|  |  | -            }
 | 
	
		
			
				|  |  | -        }
 | 
	
		
			
				|  |  | +        this.invalidateJwtCache();
 | 
	
		
			
				|  |  |      }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |      @Override
 | 
	
	
		
			
				|  | @@ -365,7 +401,7 @@ public class JwtRealm extends Realm implements CachingRealm, Releasable {
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |              // JWT cache
 | 
	
		
			
				|  |  |              final SecureString serializedJwt = jwtAuthenticationToken.getEndUserSignedJwt();
 | 
	
		
			
				|  |  | -            final BytesKey jwtCacheKey = (this.jwtCache == null) ? null : computeBytesKey(serializedJwt);
 | 
	
		
			
				|  |  | +            final BytesArray jwtCacheKey = (this.jwtCache == null) ? null : new BytesArray(JwtUtil.sha256(serializedJwt));
 | 
	
		
			
				|  |  |              if (jwtCacheKey != null) {
 | 
	
		
			
				|  |  |                  final ExpiringUser expiringUser = this.jwtCache.get(jwtCacheKey);
 | 
	
		
			
				|  |  |                  if (expiringUser == null) {
 | 
	
	
		
			
				|  | @@ -417,99 +453,201 @@ public class JwtRealm extends Realm implements CachingRealm, Releasable {
 | 
	
		
			
				|  |  |              }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |              // Validate JWT: Extract JWT and claims set, and validate JWT.
 | 
	
		
			
				|  |  | -            final SignedJWT jwt;
 | 
	
		
			
				|  |  | -            final JWTClaimsSet claimsSet;
 | 
	
		
			
				|  |  | -            try {
 | 
	
		
			
				|  |  | -                jwt = SignedJWT.parse(serializedJwt.toString());
 | 
	
		
			
				|  |  | -                final String jwtAlg = jwt.getHeader().getAlgorithm().getName();
 | 
	
		
			
				|  |  | -                final boolean isJwtAlgHmac = JwtRealmSettings.SUPPORTED_SIGNATURE_ALGORITHMS_HMAC.contains(jwtAlg);
 | 
	
		
			
				|  |  | -                final JwtRealm.JwksAlgs jwksAndAlgs = isJwtAlgHmac ? this.jwksAlgsHmac : this.jwksAlgsPkc;
 | 
	
		
			
				|  |  | -                JwtValidateUtil.validate(
 | 
	
		
			
				|  |  | -                    jwt,
 | 
	
		
			
				|  |  | -                    this.allowedIssuer,
 | 
	
		
			
				|  |  | -                    this.allowedAudiences,
 | 
	
		
			
				|  |  | -                    this.allowedClockSkew.seconds(),
 | 
	
		
			
				|  |  | -                    jwksAndAlgs.algs,
 | 
	
		
			
				|  |  | -                    jwksAndAlgs.jwks
 | 
	
		
			
				|  |  | +            validateJwt(
 | 
	
		
			
				|  |  | +                serializedJwt,
 | 
	
		
			
				|  |  | +                tokenPrincipal,
 | 
	
		
			
				|  |  | +                ActionListener.wrap(claimsSet -> processValidatedJwt(tokenPrincipal, jwtCacheKey, claimsSet, listener), ex -> {
 | 
	
		
			
				|  |  | +                    final String msg = "Realm [" + super.name() + "] JWT validation failed for token=[" + tokenPrincipal + "].";
 | 
	
		
			
				|  |  | +                    LOGGER.debug(msg, ex);
 | 
	
		
			
				|  |  | +                    listener.onResponse(AuthenticationResult.unsuccessful(msg, ex));
 | 
	
		
			
				|  |  | +                })
 | 
	
		
			
				|  |  | +            );
 | 
	
		
			
				|  |  | +        } else {
 | 
	
		
			
				|  |  | +            final String className = (authenticationToken == null) ? "null" : authenticationToken.getClass().getCanonicalName();
 | 
	
		
			
				|  |  | +            final String msg = "Realm [" + super.name() + "] does not support AuthenticationToken [" + className + "].";
 | 
	
		
			
				|  |  | +            LOGGER.trace(msg);
 | 
	
		
			
				|  |  | +            listener.onResponse(AuthenticationResult.unsuccessful(msg, null));
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    private void validateJwt(SecureString serializedJwt, String tokenPrincipal, ActionListener<JWTClaimsSet> listener) {
 | 
	
		
			
				|  |  | +        final SignedJWT jwt;
 | 
	
		
			
				|  |  | +        final JWSHeader header;
 | 
	
		
			
				|  |  | +        final JWTClaimsSet claimsSet;
 | 
	
		
			
				|  |  | +        final String alg;
 | 
	
		
			
				|  |  | +        try {
 | 
	
		
			
				|  |  | +            jwt = SignedJWT.parse(serializedJwt.toString());
 | 
	
		
			
				|  |  | +            header = jwt.getHeader();
 | 
	
		
			
				|  |  | +            alg = header.getAlgorithm().getName();
 | 
	
		
			
				|  |  | +            claimsSet = jwt.getJWTClaimsSet();
 | 
	
		
			
				|  |  | +            final Date now = new Date();
 | 
	
		
			
				|  |  | +            if (LOGGER.isDebugEnabled()) {
 | 
	
		
			
				|  |  | +                LOGGER.debug(
 | 
	
		
			
				|  |  | +                    "Realm [{}] JWT parse succeeded for token=[{}]."
 | 
	
		
			
				|  |  | +                        + "Validating JWT, now [{}], alg [{}], issuer [{}], audiences [{}], kty [{}],"
 | 
	
		
			
				|  |  | +                        + " auth_time [{}], iat [{}], nbf [{}], exp [{}], kid [{}], jti [{}]",
 | 
	
		
			
				|  |  | +                    super.name(),
 | 
	
		
			
				|  |  | +                    tokenPrincipal,
 | 
	
		
			
				|  |  | +                    now,
 | 
	
		
			
				|  |  | +                    alg,
 | 
	
		
			
				|  |  | +                    claimsSet.getIssuer(),
 | 
	
		
			
				|  |  | +                    claimsSet.getAudience(),
 | 
	
		
			
				|  |  | +                    header.getType(),
 | 
	
		
			
				|  |  | +                    claimsSet.getDateClaim("auth_time"),
 | 
	
		
			
				|  |  | +                    claimsSet.getIssueTime(),
 | 
	
		
			
				|  |  | +                    claimsSet.getNotBeforeTime(),
 | 
	
		
			
				|  |  | +                    claimsSet.getExpirationTime(),
 | 
	
		
			
				|  |  | +                    header.getKeyID(),
 | 
	
		
			
				|  |  | +                    claimsSet.getJWTID()
 | 
	
		
			
				|  |  |                  );
 | 
	
		
			
				|  |  | -                claimsSet = jwt.getJWTClaimsSet();
 | 
	
		
			
				|  |  | -                LOGGER.trace("Realm [{}] JWT validation succeeded for token=[{}].", super.name(), tokenPrincipal);
 | 
	
		
			
				|  |  | -            } catch (Exception e) {
 | 
	
		
			
				|  |  | -                final String msg = "Realm [" + super.name() + "] JWT validation failed for token=[" + tokenPrincipal + "].";
 | 
	
		
			
				|  |  | -                final AuthenticationResult<User> failure = AuthenticationResult.unsuccessful(msg, e);
 | 
	
		
			
				|  |  | -                LOGGER.debug(msg, e);
 | 
	
		
			
				|  |  | -                listener.onResponse(failure);
 | 
	
		
			
				|  |  | -                return;
 | 
	
		
			
				|  |  |              }
 | 
	
		
			
				|  |  | +            // Validate all else before signature, because these checks are more helpful diagnostics than rejected signatures.
 | 
	
		
			
				|  |  | +            final boolean isJwtAlgHmac = JwtRealmSettings.SUPPORTED_SIGNATURE_ALGORITHMS_HMAC.contains(alg);
 | 
	
		
			
				|  |  | +            JwtValidateUtil.validateType(jwt);
 | 
	
		
			
				|  |  | +            JwtValidateUtil.validateIssuer(jwt, allowedIssuer);
 | 
	
		
			
				|  |  | +            JwtValidateUtil.validateAudiences(jwt, allowedAudiences);
 | 
	
		
			
				|  |  | +            JwtValidateUtil.validateSignatureAlgorithm(jwt, isJwtAlgHmac ? this.allowedJwksAlgsHmac : this.allowedJwksAlgsPkc);
 | 
	
		
			
				|  |  | +            JwtValidateUtil.validateAuthTime(jwt, now, this.allowedClockSkew.seconds());
 | 
	
		
			
				|  |  | +            JwtValidateUtil.validateIssuedAtTime(jwt, now, this.allowedClockSkew.seconds());
 | 
	
		
			
				|  |  | +            JwtValidateUtil.validateNotBeforeTime(jwt, now, this.allowedClockSkew.seconds());
 | 
	
		
			
				|  |  | +            JwtValidateUtil.validateExpiredTime(jwt, now, this.allowedClockSkew.seconds());
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +            // At this point, client authc and JWT kty+alg+iss+aud+time filters passed. Do sig last, in case JWK reload is expensive.
 | 
	
		
			
				|  |  | +            validateSignature(jwt, isJwtAlgHmac, tokenPrincipal, listener.map(ignored -> claimsSet));
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        } catch (Exception e) {
 | 
	
		
			
				|  |  | +            listener.onFailure(e);
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -            // At this point, JWT is validated. Parse the JWT claims using realm settings.
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | -            final String principal = this.claimParserPrincipal.getClaimValue(claimsSet);
 | 
	
		
			
				|  |  | -            if (Strings.hasText(principal) == false) {
 | 
	
		
			
				|  |  | -                final String msg = "Realm ["
 | 
	
		
			
				|  |  | -                    + super.name()
 | 
	
		
			
				|  |  | -                    + "] no principal for token=["
 | 
	
		
			
				|  |  | -                    + tokenPrincipal
 | 
	
		
			
				|  |  | -                    + "] parser=["
 | 
	
		
			
				|  |  | -                    + this.claimParserPrincipal
 | 
	
		
			
				|  |  | -                    + "] claims=["
 | 
	
		
			
				|  |  | -                    + claimsSet
 | 
	
		
			
				|  |  | -                    + "].";
 | 
	
		
			
				|  |  | -                LOGGER.debug(msg);
 | 
	
		
			
				|  |  | -                listener.onResponse(AuthenticationResult.unsuccessful(msg, null));
 | 
	
		
			
				|  |  | -                return;
 | 
	
		
			
				|  |  | -            }
 | 
	
		
			
				|  |  | +    private void processValidatedJwt(
 | 
	
		
			
				|  |  | +        String tokenPrincipal,
 | 
	
		
			
				|  |  | +        BytesArray jwtCacheKey,
 | 
	
		
			
				|  |  | +        JWTClaimsSet claimsSet,
 | 
	
		
			
				|  |  | +        ActionListener<AuthenticationResult<User>> listener
 | 
	
		
			
				|  |  | +    ) {
 | 
	
		
			
				|  |  | +        // At this point, JWT is validated. Parse the JWT claims using realm settings.
 | 
	
		
			
				|  |  | +        final String principal = this.claimParserPrincipal.getClaimValue(claimsSet);
 | 
	
		
			
				|  |  | +        if (Strings.hasText(principal) == false) {
 | 
	
		
			
				|  |  | +            final String msg = "Realm ["
 | 
	
		
			
				|  |  | +                + super.name()
 | 
	
		
			
				|  |  | +                + "] no principal for token=["
 | 
	
		
			
				|  |  | +                + tokenPrincipal
 | 
	
		
			
				|  |  | +                + "] parser=["
 | 
	
		
			
				|  |  | +                + this.claimParserPrincipal
 | 
	
		
			
				|  |  | +                + "] claims=["
 | 
	
		
			
				|  |  | +                + claimsSet
 | 
	
		
			
				|  |  | +                + "].";
 | 
	
		
			
				|  |  | +            LOGGER.debug(msg);
 | 
	
		
			
				|  |  | +            listener.onResponse(AuthenticationResult.unsuccessful(msg, null));
 | 
	
		
			
				|  |  | +            return;
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -            // Roles listener: Log roles from delegated authz lookup or role mapping, and cache User if JWT cache is enabled.
 | 
	
		
			
				|  |  | -            final ActionListener<AuthenticationResult<User>> logAndCacheListener = ActionListener.wrap(result -> {
 | 
	
		
			
				|  |  | -                if (result.isAuthenticated()) {
 | 
	
		
			
				|  |  | -                    final User user = result.getValue();
 | 
	
		
			
				|  |  | -                    LOGGER.debug(
 | 
	
		
			
				|  |  | -                        () -> format("Realm [%s] roles [%s] for principal=[%s].", super.name(), join(",", user.roles()), principal)
 | 
	
		
			
				|  |  | -                    );
 | 
	
		
			
				|  |  | -                    if ((this.jwtCache != null) && (this.jwtCacheHelper != null)) {
 | 
	
		
			
				|  |  | -                        try (ReleasableLock ignored = this.jwtCacheHelper.acquireUpdateLock()) {
 | 
	
		
			
				|  |  | -                            final long expWallClockMillis = claimsSet.getExpirationTime().getTime() + this.allowedClockSkew.getMillis();
 | 
	
		
			
				|  |  | -                            this.jwtCache.put(jwtCacheKey, new ExpiringUser(result.getValue(), new Date(expWallClockMillis)));
 | 
	
		
			
				|  |  | -                        }
 | 
	
		
			
				|  |  | +        // Roles listener: Log roles from delegated authz lookup or role mapping, and cache User if JWT cache is enabled.
 | 
	
		
			
				|  |  | +        final ActionListener<AuthenticationResult<User>> logAndCacheListener = ActionListener.wrap(result -> {
 | 
	
		
			
				|  |  | +            if (result.isAuthenticated()) {
 | 
	
		
			
				|  |  | +                final User user = result.getValue();
 | 
	
		
			
				|  |  | +                LOGGER.debug(() -> format("Realm [%s] roles [%s] for principal=[%s].", super.name(), join(",", user.roles()), principal));
 | 
	
		
			
				|  |  | +                if ((this.jwtCache != null) && (this.jwtCacheHelper != null)) {
 | 
	
		
			
				|  |  | +                    try (ReleasableLock ignored = this.jwtCacheHelper.acquireUpdateLock()) {
 | 
	
		
			
				|  |  | +                        final long expWallClockMillis = claimsSet.getExpirationTime().getTime() + this.allowedClockSkew.getMillis();
 | 
	
		
			
				|  |  | +                        this.jwtCache.put(jwtCacheKey, new ExpiringUser(result.getValue(), new Date(expWallClockMillis)));
 | 
	
		
			
				|  |  |                      }
 | 
	
		
			
				|  |  |                  }
 | 
	
		
			
				|  |  | -                listener.onResponse(result);
 | 
	
		
			
				|  |  | -            }, listener::onFailure);
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | -            // Delegated role lookup or Role mapping: Use the above listener to log roles and cache User.
 | 
	
		
			
				|  |  | -            if (this.delegatedAuthorizationSupport.hasDelegation()) {
 | 
	
		
			
				|  |  | -                this.delegatedAuthorizationSupport.resolve(principal, logAndCacheListener);
 | 
	
		
			
				|  |  | -                return;
 | 
	
		
			
				|  |  |              }
 | 
	
		
			
				|  |  | +            listener.onResponse(result);
 | 
	
		
			
				|  |  | +        }, listener::onFailure);
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -            // User metadata: If enabled, extract metadata from JWT claims set. Use it in UserRoleMapper.UserData and User constructors.
 | 
	
		
			
				|  |  | -            final Map<String, Object> userMetadata;
 | 
	
		
			
				|  |  | -            try {
 | 
	
		
			
				|  |  | -                userMetadata = this.populateUserMetadata ? JwtUtil.toUserMetadata(jwt) : Map.of();
 | 
	
		
			
				|  |  | -            } catch (Exception e) {
 | 
	
		
			
				|  |  | -                final String msg = "Realm [" + super.name() + "] parse metadata failed for principal=[" + principal + "].";
 | 
	
		
			
				|  |  | -                final AuthenticationResult<User> unsuccessful = AuthenticationResult.unsuccessful(msg, e);
 | 
	
		
			
				|  |  | -                LOGGER.debug(msg, e);
 | 
	
		
			
				|  |  | -                listener.onResponse(unsuccessful);
 | 
	
		
			
				|  |  | +        // Delegated role lookup or Role mapping: Use the above listener to log roles and cache User.
 | 
	
		
			
				|  |  | +        if (this.delegatedAuthorizationSupport.hasDelegation()) {
 | 
	
		
			
				|  |  | +            this.delegatedAuthorizationSupport.resolve(principal, logAndCacheListener);
 | 
	
		
			
				|  |  | +            return;
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        // User metadata: If enabled, extract metadata from JWT claims set. Use it in UserRoleMapper.UserData and User constructors.
 | 
	
		
			
				|  |  | +        final Map<String, Object> userMetadata;
 | 
	
		
			
				|  |  | +        try {
 | 
	
		
			
				|  |  | +            userMetadata = this.populateUserMetadata ? JwtUtil.toUserMetadata(claimsSet) : Map.of();
 | 
	
		
			
				|  |  | +        } catch (Exception e) {
 | 
	
		
			
				|  |  | +            final String msg = "Realm [" + super.name() + "] parse metadata failed for principal=[" + principal + "].";
 | 
	
		
			
				|  |  | +            LOGGER.debug(msg, e);
 | 
	
		
			
				|  |  | +            listener.onResponse(AuthenticationResult.unsuccessful(msg, e));
 | 
	
		
			
				|  |  | +            return;
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        // Role resolution: Handle role mapping in JWT Realm.
 | 
	
		
			
				|  |  | +        final List<String> groups = this.claimParserGroups.getClaimValues(claimsSet);
 | 
	
		
			
				|  |  | +        final String dn = this.claimParserDn.getClaimValue(claimsSet);
 | 
	
		
			
				|  |  | +        final String mail = this.claimParserMail.getClaimValue(claimsSet);
 | 
	
		
			
				|  |  | +        final String name = this.claimParserName.getClaimValue(claimsSet);
 | 
	
		
			
				|  |  | +        final UserRoleMapper.UserData userData = new UserRoleMapper.UserData(principal, dn, groups, userMetadata, super.config);
 | 
	
		
			
				|  |  | +        this.userRoleMapper.resolveRoles(userData, ActionListener.wrap(rolesSet -> {
 | 
	
		
			
				|  |  | +            final User user = new User(principal, rolesSet.toArray(Strings.EMPTY_ARRAY), name, mail, userData.getMetadata(), true);
 | 
	
		
			
				|  |  | +            logAndCacheListener.onResponse(AuthenticationResult.success(user));
 | 
	
		
			
				|  |  | +        }, logAndCacheListener::onFailure));
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    private void validateSignature(
 | 
	
		
			
				|  |  | +        final SignedJWT jwt,
 | 
	
		
			
				|  |  | +        final boolean isJwtAlgHmac,
 | 
	
		
			
				|  |  | +        final String tokenPrincipal,
 | 
	
		
			
				|  |  | +        final ActionListener<Void> listener
 | 
	
		
			
				|  |  | +    ) throws Exception {
 | 
	
		
			
				|  |  | +        try {
 | 
	
		
			
				|  |  | +            JwtValidateUtil.validateSignature(
 | 
	
		
			
				|  |  | +                jwt,
 | 
	
		
			
				|  |  | +                isJwtAlgHmac ? this.contentAndJwksAlgsHmac.jwksAlgs.jwks : this.contentAndJwksAlgsPkc.jwksAlgs.jwks
 | 
	
		
			
				|  |  | +            );
 | 
	
		
			
				|  |  | +            listener.onResponse(null);
 | 
	
		
			
				|  |  | +        } catch (Exception primaryException) {
 | 
	
		
			
				|  |  | +            if (isJwtAlgHmac || this.jwkSetLoader == null) {
 | 
	
		
			
				|  |  | +                listener.onFailure(primaryException); // HMAC reload not supported at this time
 | 
	
		
			
				|  |  |                  return;
 | 
	
		
			
				|  |  |              }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -            // Role resolution: Handle role mapping in JWT Realm.
 | 
	
		
			
				|  |  | -            final List<String> groups = this.claimParserGroups.getClaimValues(claimsSet);
 | 
	
		
			
				|  |  | -            final String dn = this.claimParserDn.getClaimValue(claimsSet);
 | 
	
		
			
				|  |  | -            final String mail = this.claimParserMail.getClaimValue(claimsSet);
 | 
	
		
			
				|  |  | -            final String name = this.claimParserName.getClaimValue(claimsSet);
 | 
	
		
			
				|  |  | -            final UserRoleMapper.UserData userData = new UserRoleMapper.UserData(principal, dn, groups, userMetadata, super.config);
 | 
	
		
			
				|  |  | -            this.userRoleMapper.resolveRoles(userData, ActionListener.wrap(rolesSet -> {
 | 
	
		
			
				|  |  | -                final User user = new User(principal, rolesSet.toArray(Strings.EMPTY_ARRAY), name, mail, userData.getMetadata(), true);
 | 
	
		
			
				|  |  | -                logAndCacheListener.onResponse(AuthenticationResult.success(user));
 | 
	
		
			
				|  |  | -            }, logAndCacheListener::onFailure));
 | 
	
		
			
				|  |  | -        } else {
 | 
	
		
			
				|  |  | -            final String className = (authenticationToken == null) ? "null" : authenticationToken.getClass().getCanonicalName();
 | 
	
		
			
				|  |  | -            final String msg = "Realm [" + super.name() + "] does not support AuthenticationToken [" + className + "].";
 | 
	
		
			
				|  |  | -            LOGGER.trace(msg);
 | 
	
		
			
				|  |  | -            listener.onResponse(AuthenticationResult.unsuccessful(msg, null));
 | 
	
		
			
				|  |  | +            LOGGER.debug(
 | 
	
		
			
				|  |  | +                () -> org.elasticsearch.core.Strings.format(
 | 
	
		
			
				|  |  | +                    "Signature verification failed for [%s] reloading JWKSet (was: #[%s] JWKs, #[%s] algs, sha256=[%s])",
 | 
	
		
			
				|  |  | +                    tokenPrincipal,
 | 
	
		
			
				|  |  | +                    this.contentAndJwksAlgsPkc.jwksAlgs.jwks().size(),
 | 
	
		
			
				|  |  | +                    this.contentAndJwksAlgsPkc.jwksAlgs.algs().size(),
 | 
	
		
			
				|  |  | +                    MessageDigests.toHexString(this.contentAndJwksAlgsPkc.sha256())
 | 
	
		
			
				|  |  | +                ),
 | 
	
		
			
				|  |  | +                primaryException
 | 
	
		
			
				|  |  | +            );
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +            this.jwkSetLoader.load(ActionListener.wrap(newContentAndJwksAlgs -> {
 | 
	
		
			
				|  |  | +                if (Arrays.equals(this.contentAndJwksAlgsPkc.sha256, newContentAndJwksAlgs.sha256)) {
 | 
	
		
			
				|  |  | +                    // No change in JWKSet
 | 
	
		
			
				|  |  | +                    logger.debug("Reloaded same PKC JWKs, can't retry verify JWT token=[{}]", tokenPrincipal);
 | 
	
		
			
				|  |  | +                    listener.onFailure(primaryException);
 | 
	
		
			
				|  |  | +                    return;
 | 
	
		
			
				|  |  | +                }
 | 
	
		
			
				|  |  | +                this.contentAndJwksAlgsPkc = newContentAndJwksAlgs;
 | 
	
		
			
				|  |  | +                // If all PKC JWKs were replaced, all PKC JWT cache entries need to be invalidated.
 | 
	
		
			
				|  |  | +                // Enhancement idea: Use separate caches for PKC vs HMAC JWKs, so only PKC entries get invalidated.
 | 
	
		
			
				|  |  | +                // Enhancement idea: When some JWKs are retained (ex: rotation), only invalidate for removed JWKs.
 | 
	
		
			
				|  |  | +                this.invalidateJwtCache();
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +                if (this.contentAndJwksAlgsPkc.jwksAlgs.isEmpty()) {
 | 
	
		
			
				|  |  | +                    logger.debug("Reloaded empty PKC JWKs, verification of JWT token will fail [{}]", tokenPrincipal);
 | 
	
		
			
				|  |  | +                    // fall through and let try/catch below handle empty JWKs failure log and response
 | 
	
		
			
				|  |  | +                }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +                try {
 | 
	
		
			
				|  |  | +                    JwtValidateUtil.validateSignature(jwt, this.contentAndJwksAlgsPkc.jwksAlgs.jwks);
 | 
	
		
			
				|  |  | +                    listener.onResponse(null);
 | 
	
		
			
				|  |  | +                } catch (Exception secondaryException) {
 | 
	
		
			
				|  |  | +                    logger.debug(
 | 
	
		
			
				|  |  | +                        "Verification of JWT token for [{}] failed - original failure=[{}], failure after reload=[{}]",
 | 
	
		
			
				|  |  | +                        tokenPrincipal,
 | 
	
		
			
				|  |  | +                        primaryException.getMessage(),
 | 
	
		
			
				|  |  | +                        secondaryException.getMessage()
 | 
	
		
			
				|  |  | +                    );
 | 
	
		
			
				|  |  | +                    secondaryException.addSuppressed(primaryException);
 | 
	
		
			
				|  |  | +                    listener.onFailure(secondaryException);
 | 
	
		
			
				|  |  | +                }
 | 
	
		
			
				|  |  | +            }, listener::onFailure));
 | 
	
		
			
				|  |  |          }
 | 
	
		
			
				|  |  |      }
 | 
	
		
			
				|  |  |  
 | 
	
	
		
			
				|  | @@ -522,9 +660,75 @@ public class JwtRealm extends Realm implements CachingRealm, Releasable {
 | 
	
		
			
				|  |  |          }, listener::onFailure));
 | 
	
		
			
				|  |  |      }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -    static BytesKey computeBytesKey(final CharSequence charSequence) {
 | 
	
		
			
				|  |  | -        final MessageDigest messageDigest = MessageDigests.sha256();
 | 
	
		
			
				|  |  | -        messageDigest.update(charSequence.toString().getBytes(StandardCharsets.UTF_8));
 | 
	
		
			
				|  |  | -        return new BytesKey(messageDigest.digest());
 | 
	
		
			
				|  |  | +    private class JwkSetLoader {
 | 
	
		
			
				|  |  | +        private final AtomicReference<ListenableFuture<ContentAndJwksAlgs>> reloadFutureRef = new AtomicReference<>();
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        void load(final ActionListener<ContentAndJwksAlgs> listener) {
 | 
	
		
			
				|  |  | +            final ListenableFuture<ContentAndJwksAlgs> future = this.getFuture();
 | 
	
		
			
				|  |  | +            future.addListener(listener);
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        private ListenableFuture<ContentAndJwksAlgs> getFuture() {
 | 
	
		
			
				|  |  | +            for (;;) {
 | 
	
		
			
				|  |  | +                final ListenableFuture<ContentAndJwksAlgs> existingFuture = this.reloadFutureRef.get();
 | 
	
		
			
				|  |  | +                if (existingFuture != null) {
 | 
	
		
			
				|  |  | +                    return existingFuture;
 | 
	
		
			
				|  |  | +                }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +                final ListenableFuture<ContentAndJwksAlgs> newFuture = new ListenableFuture<>();
 | 
	
		
			
				|  |  | +                if (this.reloadFutureRef.compareAndSet(null, newFuture)) {
 | 
	
		
			
				|  |  | +                    loadInternal(ActionListener.runAfter(newFuture, () -> this.reloadFutureRef.compareAndSet(newFuture, null)));
 | 
	
		
			
				|  |  | +                    return newFuture;
 | 
	
		
			
				|  |  | +                }
 | 
	
		
			
				|  |  | +                // else, Another thread set the future-ref before us, just try it all again
 | 
	
		
			
				|  |  | +            }
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        private void loadInternal(final ActionListener<ContentAndJwksAlgs> listener) {
 | 
	
		
			
				|  |  | +            // PKC JWKSet get contents from local file or remote HTTPS URL
 | 
	
		
			
				|  |  | +            if (JwtRealm.this.httpClient == null) {
 | 
	
		
			
				|  |  | +                LOGGER.trace("Loading PKC JWKs from path [{}]", JwtRealm.this.jwkSetPath);
 | 
	
		
			
				|  |  | +                listener.onResponse(
 | 
	
		
			
				|  |  | +                    this.parseContent(
 | 
	
		
			
				|  |  | +                        JwtUtil.readFileContents(
 | 
	
		
			
				|  |  | +                            RealmSettings.getFullSettingKey(JwtRealm.this.config, JwtRealmSettings.PKC_JWKSET_PATH),
 | 
	
		
			
				|  |  | +                            JwtRealm.this.jwkSetPath,
 | 
	
		
			
				|  |  | +                            JwtRealm.this.config.env()
 | 
	
		
			
				|  |  | +                        )
 | 
	
		
			
				|  |  | +                    )
 | 
	
		
			
				|  |  | +                );
 | 
	
		
			
				|  |  | +            } else {
 | 
	
		
			
				|  |  | +                LOGGER.trace("Loading PKC JWKs from https URI [{}]", JwtRealm.this.jwkSetPathUri);
 | 
	
		
			
				|  |  | +                JwtUtil.readUriContents(
 | 
	
		
			
				|  |  | +                    RealmSettings.getFullSettingKey(JwtRealm.this.config, JwtRealmSettings.PKC_JWKSET_PATH),
 | 
	
		
			
				|  |  | +                    JwtRealm.this.jwkSetPathUri,
 | 
	
		
			
				|  |  | +                    JwtRealm.this.httpClient,
 | 
	
		
			
				|  |  | +                    listener.map(bytes -> {
 | 
	
		
			
				|  |  | +                        LOGGER.trace("Loaded bytes [{}] from [{}]", bytes.length, JwtRealm.this.jwkSetPathUri);
 | 
	
		
			
				|  |  | +                        return this.parseContent(bytes);
 | 
	
		
			
				|  |  | +                    })
 | 
	
		
			
				|  |  | +                );
 | 
	
		
			
				|  |  | +            }
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        private ContentAndJwksAlgs parseContent(final byte[] jwkSetContentBytesPkc) {
 | 
	
		
			
				|  |  | +            final String jwkSetContentsPkc = new String(jwkSetContentBytesPkc, StandardCharsets.UTF_8);
 | 
	
		
			
				|  |  | +            final byte[] jwkSetContentsPkcSha256 = JwtUtil.sha256(jwkSetContentsPkc);
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +            // PKC JWKSet parse contents
 | 
	
		
			
				|  |  | +            final List<JWK> jwksPkc = JwkValidateUtil.loadJwksFromJwkSetString(
 | 
	
		
			
				|  |  | +                RealmSettings.getFullSettingKey(JwtRealm.this.config, JwtRealmSettings.PKC_JWKSET_PATH),
 | 
	
		
			
				|  |  | +                jwkSetContentsPkc
 | 
	
		
			
				|  |  | +            );
 | 
	
		
			
				|  |  | +            // Filter JWK(s) vs signature algorithms. Only keep JWKs with a matching alg. Only keep algs with a matching JWK.
 | 
	
		
			
				|  |  | +            final JwksAlgs jwksAlgsPkc = JwkValidateUtil.filterJwksAndAlgorithms(jwksPkc, JwtRealm.this.allowedJwksAlgsPkc);
 | 
	
		
			
				|  |  | +            LOGGER.info(
 | 
	
		
			
				|  |  | +                "Usable PKC: JWKs=[{}] algorithms=[{}] sha256=[{}]",
 | 
	
		
			
				|  |  | +                jwksAlgsPkc.jwks().size(),
 | 
	
		
			
				|  |  | +                String.join(",", jwksAlgsPkc.algs()),
 | 
	
		
			
				|  |  | +                MessageDigests.toHexString(jwkSetContentsPkcSha256)
 | 
	
		
			
				|  |  | +            );
 | 
	
		
			
				|  |  | +            return new ContentAndJwksAlgs(jwkSetContentsPkcSha256, jwksAlgsPkc);
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  |      }
 | 
	
		
			
				|  |  |  }
 |