diff --git a/changelog/unreleased/SOLR-18211-migrate-jwt-nimbus.yml b/changelog/unreleased/SOLR-18211-migrate-jwt-nimbus.yml
new file mode 100644
index 000000000000..d2b8c048fd6d
--- /dev/null
+++ b/changelog/unreleased/SOLR-18211-migrate-jwt-nimbus.yml
@@ -0,0 +1,8 @@
+title: "Replace unmaintained bc-jose4j JWT library with nimbus-jose-jwt in the jwt-auth module."
+type: dependency_update
+authors:
+ - name: Jan Høydahl
+ url: https://home.apache.org/phonebook.html?uid=janhoy
+links:
+ - name: SOLR-18211
+ url: https://issues.apache.org/jira/browse/SOLR-18211
diff --git a/solr/modules/jwt-auth/build.gradle b/solr/modules/jwt-auth/build.gradle
index 89f4f1a4ac71..a3f9b3644305 100644
--- a/solr/modules/jwt-auth/build.gradle
+++ b/solr/modules/jwt-auth/build.gradle
@@ -35,7 +35,7 @@ dependencies {
implementation project(':solr:solrj')
implementation project(':solr:solrj-jetty')
- implementation libs.bc.jose4j
+ implementation libs.nimbusds.josejwt
implementation libs.eclipse.jetty.client
implementation libs.eclipse.jetty.http
@@ -62,7 +62,6 @@ dependencies {
testImplementation libs.bouncycastle.bcpkix
testImplementation libs.bouncycastle.bcprov
- testImplementation libs.nimbusds.josejwt
testImplementation libs.squareup.okhttp3.mockwebserver
testImplementation libs.squareup.okhttp3.okhttp
testRuntimeOnly libs.netty.codechttp
diff --git a/solr/modules/jwt-auth/gradle.lockfile b/solr/modules/jwt-auth/gradle.lockfile
index de3055b0b4b2..64570cb8d407 100644
--- a/solr/modules/jwt-auth/gradle.lockfile
+++ b/solr/modules/jwt-auth/gradle.lockfile
@@ -34,7 +34,7 @@ com.jayway.jsonpath:json-path:2.9.0=jarValidation,runtimeClasspath,runtimeLibs,s
com.lmax:disruptor:4.0.0=solrPlatformLibs
com.nimbusds:content-type:2.2=jarValidation,testCompileClasspath,testRuntimeClasspath
com.nimbusds:lang-tag:1.7=jarValidation,testCompileClasspath,testRuntimeClasspath
-com.nimbusds:nimbus-jose-jwt:10.5=jarValidation,testCompileClasspath,testRuntimeClasspath
+com.nimbusds:nimbus-jose-jwt:10.5=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
com.nimbusds:oauth2-oidc-sdk:10.10.1=jarValidation,testCompileClasspath,testRuntimeClasspath
com.squareup.okhttp3:mockwebserver:4.12.0=jarValidation,testCompileClasspath,testRuntimeClasspath
com.squareup.okhttp3:okhttp:4.12.0=jarValidation,testCompileClasspath,testRuntimeClasspath
@@ -137,7 +137,6 @@ org.apache.lucene:lucene-test-framework:10.4.0=jarValidation,testCompileClasspat
org.apache.zookeeper:zookeeper-jute:3.9.4=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath
org.apache.zookeeper:zookeeper:3.9.4=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath
org.apiguardian:apiguardian-api:1.1.2=jarValidation,testRuntimeClasspath
-org.bitbucket.b_c:jose4j:0.9.6=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
org.bouncycastle:bcpkix-jdk15on:1.70=jarValidation,testRuntimeClasspath
org.bouncycastle:bcpkix-jdk18on:1.84=jarValidation,testCompileClasspath,testRuntimeClasspath
org.bouncycastle:bcprov-jdk15on:1.70=jarValidation,testRuntimeClasspath
diff --git a/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/IssuerAwareJWSKeySelector.java b/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/IssuerAwareJWSKeySelector.java
new file mode 100644
index 000000000000..a1dc6dae8af0
--- /dev/null
+++ b/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/IssuerAwareJWSKeySelector.java
@@ -0,0 +1,218 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.security.jwt;
+
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.KeySourceException;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.JWKMatcher;
+import com.nimbusds.jose.jwk.JWKSelector;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.OctetSequenceKey;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jose.proc.JWSKeySelector;
+import com.nimbusds.jose.proc.SecurityContext;
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.security.Key;
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import javax.net.ssl.SSLHandshakeException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Resolves JWS signature verification keys from a set of {@link JWTIssuerConfig} objects, which may
+ * represent any valid configuration in Solr's security.json, i.e. static list of JWKs or keys
+ * retrieved from HTTPS JWK endpoints.
+ *
+ *
This implementation maintains a map of issuers, each with its own list of {@link JWK}, and
+ * resolves the correct key from the correct issuer. The issuer is passed in via {@link
+ * IssuerContext}.
+ *
+ *
If a key is not found and the issuer is backed by HTTPS JWKs, one cache refresh is attempted
+ * before failing.
+ */
+public class IssuerAwareJWSKeySelector implements JWSKeySelector {
+ private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+ private final Map issuerConfigs = new HashMap<>();
+ private final boolean requireIssuer;
+
+ /**
+ * SecurityContext subclass that carries the (unverified) issuer claim from the JWT payload,
+ * allowing the key selector to look up the correct issuer configuration.
+ */
+ public static class IssuerContext implements SecurityContext {
+ private final String issuer;
+
+ public IssuerContext(String issuer) {
+ this.issuer = issuer;
+ }
+
+ public String getIssuer() {
+ return issuer;
+ }
+ }
+
+ /**
+ * Resolves key from a JWKs from one or more IssuerConfigs
+ *
+ * @param issuerConfigs Collection of configuration objects for the issuer(s)
+ * @param requireIssuer if true, will require 'iss' claim on jws
+ */
+ public IssuerAwareJWSKeySelector(
+ Collection issuerConfigs, boolean requireIssuer) {
+ this.requireIssuer = requireIssuer;
+ issuerConfigs.forEach(ic -> this.issuerConfigs.put(ic.getIss(), ic));
+ }
+
+ @Override
+ public List extends Key> selectJWSKeys(JWSHeader header, SecurityContext context)
+ throws KeySourceException {
+ String tokenIssuer =
+ (context instanceof IssuerContext) ? ((IssuerContext) context).getIssuer() : null;
+
+ JWTIssuerConfig issuerConfig = resolveIssuerConfig(tokenIssuer);
+
+ List allJwks = new ArrayList<>();
+ String keysSource = "N/A";
+ try {
+ if (issuerConfig.usesHttpsJwk()) {
+ keysSource = "[" + String.join(", ", issuerConfig.getJwksUrls()) + "]";
+ for (JWTIssuerConfig.JwkSetFetcher fetcher : issuerConfig.getHttpsJwks()) {
+ try {
+ allJwks.addAll(fetcher.getKeys());
+ } catch (SSLHandshakeException e) {
+ throw new KeySourceException(
+ "Failed to connect with "
+ + fetcher.getLocation()
+ + ", do you have the correct SSL certificate configured?",
+ e);
+ }
+ }
+ } else {
+ keysSource = "static list of keys in security.json";
+ allJwks.addAll(issuerConfig.getJsonWebKeySet().getKeys());
+ }
+ } catch (IOException | ParseException e) {
+ throw new KeySourceException(
+ String.format(
+ Locale.ROOT, "Unable to fetch JWKs from source %s: %s", keysSource, e.getMessage()),
+ e);
+ }
+
+ JWKSelector selector = new JWKSelector(JWKMatcher.forJWSHeader(header));
+ List matchingJwks = selector.select(new JWKSet(allJwks));
+
+ if (matchingJwks.isEmpty() && issuerConfig.usesHttpsJwk()) {
+ if (log.isDebugEnabled()) {
+ log.debug(
+ "No matching key found for JWS header {} in {} keys from {}; refreshing",
+ header,
+ allJwks.size(),
+ keysSource);
+ }
+ allJwks.clear();
+ try {
+ for (JWTIssuerConfig.JwkSetFetcher fetcher : issuerConfig.getHttpsJwks()) {
+ fetcher.refresh();
+ allJwks.addAll(fetcher.getKeys());
+ }
+ } catch (IOException | ParseException e) {
+ throw new KeySourceException("Failed to refresh JWKs from " + keysSource + ": " + e, e);
+ }
+ matchingJwks = selector.select(new JWKSet(allJwks));
+ }
+
+ if (matchingJwks.isEmpty()) {
+ throw new KeySourceException(
+ String.format(
+ Locale.ROOT,
+ "Unable to find a suitable verification key for JWS w/ header %s from %d keys from source %s",
+ header,
+ allJwks.size(),
+ keysSource));
+ }
+
+ List keys = new ArrayList<>();
+ for (JWK jwk : matchingJwks) {
+ try {
+ if (jwk instanceof RSAKey) {
+ keys.add(((RSAKey) jwk).toPublicKey());
+ } else if (jwk instanceof ECKey) {
+ keys.add(((ECKey) jwk).toPublicKey());
+ } else if (jwk instanceof OctetSequenceKey) {
+ keys.add(((OctetSequenceKey) jwk).toSecretKey());
+ } else {
+ log.warn("Unsupported JWK type: {}", jwk.getKeyType());
+ }
+ } catch (JOSEException e) {
+ log.warn("Failed to convert JWK to Key", e);
+ }
+ }
+
+ if (keys.isEmpty()) {
+ throw new KeySourceException(
+ "Could not extract a usable public key from matched JWK(s) for header " + header);
+ }
+
+ return keys;
+ }
+
+ private JWTIssuerConfig resolveIssuerConfig(String tokenIssuer) throws KeySourceException {
+ if (tokenIssuer == null) {
+ if (requireIssuer) {
+ throw new KeySourceException("Token does not contain required issuer claim");
+ } else if (issuerConfigs.size() == 1) {
+ return issuerConfigs.values().iterator().next();
+ } else {
+ throw new KeySourceException(
+ "Signature verification not supported for multiple issuers without 'iss' claim in token.");
+ }
+ } else {
+ JWTIssuerConfig config = issuerConfigs.get(tokenIssuer);
+ if (config == null) {
+ if (issuerConfigs.size() > 1) {
+ throw new KeySourceException(
+ "No issuers configured for iss='" + tokenIssuer + "', cannot validate signature");
+ } else if (issuerConfigs.size() == 1) {
+ config = issuerConfigs.values().iterator().next();
+ log.debug(
+ "No issuer matching token's iss claim, but exactly one configured, selecting that one");
+ } else {
+ throw new KeySourceException(
+ "Signature verification failed due to no configured issuer with id " + tokenIssuer);
+ }
+ }
+ return config;
+ }
+ }
+
+ Set getIssuerConfigs() {
+ return new HashSet<>(issuerConfigs.values());
+ }
+}
diff --git a/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTAuthPlugin.java b/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTAuthPlugin.java
index 3e60cd71898a..749a4b005a03 100644
--- a/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTAuthPlugin.java
+++ b/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTAuthPlugin.java
@@ -16,6 +16,15 @@
*/
package org.apache.solr.security.jwt;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.proc.BadJOSEException;
+import com.nimbusds.jose.proc.BadJWSException;
+import com.nimbusds.jose.proc.SecurityContext;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+import com.nimbusds.jwt.proc.BadJWTException;
+import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
+import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -27,6 +36,7 @@
import java.nio.file.Path;
import java.security.Principal;
import java.security.cert.X509Certificate;
+import java.text.ParseException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
@@ -61,15 +71,6 @@
import org.apache.solr.util.CryptoKeys;
import org.eclipse.jetty.client.Request;
import org.eclipse.jetty.http.HttpHeader;
-import org.jose4j.jwa.AlgorithmConstraints;
-import org.jose4j.jwk.HttpsJwks;
-import org.jose4j.jwt.JwtClaims;
-import org.jose4j.jwt.MalformedClaimException;
-import org.jose4j.jwt.consumer.InvalidJwtException;
-import org.jose4j.jwt.consumer.InvalidJwtSignatureException;
-import org.jose4j.jwt.consumer.JwtConsumer;
-import org.jose4j.jwt.consumer.JwtConsumerBuilder;
-import org.jose4j.lang.JoseException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -127,7 +128,7 @@ public class JWTAuthPlugin extends AuthenticationPlugin
JWTIssuerConfig.PARAM_TOKEN_ENDPOINT,
JWTIssuerConfig.PARAM_AUTHORIZATION_FLOW);
- private JwtConsumer jwtConsumer;
+ private DefaultJWTProcessor jwtProcessor;
private boolean requireExpirationTime;
private List algAllowlist;
private String principalClaim;
@@ -141,7 +142,7 @@ public class JWTAuthPlugin extends AuthenticationPlugin
private List redirectUris;
private List issuerConfigs;
private boolean requireIssuer;
- private JWTVerificationkeyResolver verificationKeyResolver;
+ private IssuerAwareJWSKeySelector verificationKeyResolver;
private Collection trustedSslCerts;
String realm;
private final CoreContainer coreContainer;
@@ -239,7 +240,7 @@ public void init(Map pluginConfig) {
// Add issuers from 'issuers' key
issuerConfigs.addAll(parseIssuers(pluginConfig));
- verificationKeyResolver = new JWTVerificationkeyResolver(issuerConfigs, requireIssuer);
+ verificationKeyResolver = new IssuerAwareJWSKeySelector(issuerConfigs, requireIssuer);
if (!issuerConfigs.isEmpty() && getPrimaryIssuer().getAuthorizationEndpoint() != null) {
adminUiScope = (String) pluginConfig.get(PARAM_ADMINUI_SCOPE);
@@ -354,9 +355,9 @@ private Optional parseIssuerFromTopLevelConfig(Map issuerConfig =
issuerConfigs.stream().filter(ic -> issuer.equals(ic.getIss())).findFirst();
@@ -452,8 +453,12 @@ public boolean doAuthenticate(
"Signature validation failed for issuer {}. Refreshing JWKs from IdP before trying again: {}",
issuer,
exceptionMessage);
- for (HttpsJwks httpsJwks : issuerConfig.get().getHttpsJwks()) {
- httpsJwks.refresh();
+ for (JWTIssuerConfig.JwkSetFetcher fetcher : issuerConfig.get().getHttpsJwks()) {
+ try {
+ fetcher.refresh();
+ } catch (IOException | ParseException e) {
+ log.warn("Failed to refresh JWKs from {}", fetcher.getLocation(), e);
+ }
}
authResponse = authenticate(header); // Retry
exceptionMessage =
@@ -462,7 +467,7 @@ public boolean doAuthenticate(
: "";
}
}
- } catch (InvalidJwtException ex) {
+ } catch (ParseException ex) {
/* ignored */
}
}
@@ -561,150 +566,194 @@ protected JWTAuthenticationResponse authenticate(String authorizationHeader) {
String jwtCompact = parseAuthorizationHeader(authorizationHeader);
if (jwtCompact != null) {
try {
+ // Pre-parse JWT (without signature verification) to get issuer and check algorithm
+ SignedJWT signedJWT;
try {
- JwtClaims jwtClaims = jwtConsumer.processToClaims(jwtCompact);
- String principal = jwtClaims.getStringClaimValue(principalClaim);
- if (principal == null || principal.isEmpty()) {
- return new JWTAuthenticationResponse(
- AuthCode.PRINCIPAL_MISSING,
- "Cannot identify principal from JWT. Required claim "
- + principalClaim
- + " missing. Cannot authenticate");
+ signedJWT = SignedJWT.parse(jwtCompact);
+ } catch (ParseException e) {
+ if (e.getMessage() != null
+ && e.getMessage().contains("Invalid JOSE Compact Serialization")) {
+ return new JWTAuthenticationResponse(AuthCode.JWT_PARSE_ERROR, e.getMessage());
}
- if (claimsMatchCompiled != null) {
- for (Map.Entry entry : claimsMatchCompiled.entrySet()) {
- String claim = entry.getKey();
- if (jwtClaims.hasClaim(claim)) {
- Object claimValue = jwtClaims.getClaimValue(claim);
- String claimValueStr = (claimValue != null) ? String.valueOf(claimValue) : "";
- if (!entry.getValue().matcher(claimValueStr).matches()) {
- return new JWTAuthenticationResponse(
- AuthCode.CLAIM_MISMATCH,
- "Claim "
- + claim
- + "="
- + claimValueStr
- + " does not match required regular expression "
- + entry.getValue().pattern());
- }
- } else {
- return new JWTAuthenticationResponse(
- AuthCode.CLAIM_MISMATCH,
- "Claim " + claim + " is required but does not exist in JWT");
- }
- }
+ return new JWTAuthenticationResponse(
+ AuthCode.JWT_PARSE_ERROR, "Failed to parse JWT: " + e.getMessage());
+ }
+
+ // Check algorithm allowlist before full processing
+ if (algAllowlist != null
+ && !algAllowlist.contains(signedJWT.getHeader().getAlgorithm().getName())) {
+ return new JWTAuthenticationResponse(
+ AuthCode.JWT_VALIDATION_EXCEPTION,
+ new RuntimeException(
+ "Algorithm "
+ + signedJWT.getHeader().getAlgorithm()
+ + " is not a permitted algorithm"));
+ }
+
+ // Extract unverified issuer to pass to key selector via context
+ String tokenIssuer;
+ try {
+ tokenIssuer = signedJWT.getJWTClaimsSet().getIssuer();
+ } catch (ParseException e) {
+ return new JWTAuthenticationResponse(
+ AuthCode.JWT_PARSE_ERROR, "Malformed claim: " + e.getMessage());
+ }
+
+ JWTClaimsSet jwtClaims;
+ try {
+ // Use the already-parsed SignedJWT to avoid redundant parsing and ParseException
+ jwtClaims =
+ jwtProcessor.process(
+ signedJWT, new IssuerAwareJWSKeySelector.IssuerContext(tokenIssuer));
+ } catch (BadJWSException e) {
+ return new JWTAuthenticationResponse(AuthCode.SIGNATURE_INVALID, e);
+ } catch (BadJWTException e) {
+ String msg = e.getMessage();
+ if (msg != null && (msg.startsWith("Expired JWT") || msg.contains("expir"))) {
+ return new JWTAuthenticationResponse(
+ AuthCode.JWT_EXPIRED, "Authentication failed due to expired JWT token. " + msg);
}
- if (!requiredScopes.isEmpty() && !jwtClaims.hasClaim(CLAIM_SCOPE)) {
- // Fail if we require scopes but they don't exist
+ return new JWTAuthenticationResponse(AuthCode.JWT_VALIDATION_EXCEPTION, e);
+ } catch (BadJOSEException e) {
+ return new JWTAuthenticationResponse(AuthCode.JWT_VALIDATION_EXCEPTION, e);
+ } catch (JOSEException e) {
+ return new JWTAuthenticationResponse(AuthCode.JWT_VALIDATION_EXCEPTION, e);
+ }
+
+ // Validate issuer value whenever the token carries an iss claim and at least one issuer
+ // is explicitly configured. requireIssuer controls whether iss must be present, not
+ // whether a mismatching value should be silently accepted when it is present.
+ if (tokenIssuer != null && issuerConfigs.stream().anyMatch(ic -> ic.getIss() != null)) {
+ boolean issuerMatched =
+ issuerConfigs.stream()
+ .anyMatch(ic -> ic.getIss() != null && ic.getIss().equals(tokenIssuer));
+ if (!issuerMatched) {
return new JWTAuthenticationResponse(
- AuthCode.CLAIM_MISMATCH,
- "Claim " + CLAIM_SCOPE + " is required but does not exist in JWT");
+ AuthCode.JWT_VALIDATION_EXCEPTION,
+ new RuntimeException(
+ "Token issuer '" + tokenIssuer + "' does not match any configured issuer"));
}
+ }
- // Find scopes for user
- Set scopes = Set.of();
- Object scopesObj = jwtClaims.getClaimValue(CLAIM_SCOPE);
- if (scopesObj != null) {
- if (scopesObj instanceof String) {
- scopes = new HashSet<>(Arrays.asList(((String) scopesObj).split("\\s+")));
- } else if (scopesObj instanceof List) {
- scopes = new HashSet<>(jwtClaims.getStringListClaimValue(CLAIM_SCOPE));
- }
- // Validate that at least one of the required scopes are present in the scope claim
- if (!requiredScopes.isEmpty()) {
- if (scopes.stream().noneMatch(requiredScopes::contains)) {
+ String principal = jwtClaims.getStringClaim(principalClaim);
+ if (principal == null || principal.isEmpty()) {
+ return new JWTAuthenticationResponse(
+ AuthCode.PRINCIPAL_MISSING,
+ "Cannot identify principal from JWT. Required claim "
+ + principalClaim
+ + " missing. Cannot authenticate");
+ }
+ if (claimsMatchCompiled != null) {
+ for (Map.Entry entry : claimsMatchCompiled.entrySet()) {
+ String claim = entry.getKey();
+ if (jwtClaims.getClaim(claim) != null) {
+ Object claimValue = jwtClaims.getClaim(claim);
+ String claimValueStr = (claimValue != null) ? String.valueOf(claimValue) : "";
+ if (!entry.getValue().matcher(claimValueStr).matches()) {
return new JWTAuthenticationResponse(
- AuthCode.SCOPE_MISSING,
+ AuthCode.CLAIM_MISMATCH,
"Claim "
- + CLAIM_SCOPE
- + " does not contain any of the required scopes: "
- + requiredScopes);
+ + claim
+ + "="
+ + claimValueStr
+ + " does not match required regular expression "
+ + entry.getValue().pattern());
}
+ } else {
+ return new JWTAuthenticationResponse(
+ AuthCode.CLAIM_MISMATCH,
+ "Claim " + claim + " is required but does not exist in JWT");
}
}
+ }
+ if (!requiredScopes.isEmpty() && jwtClaims.getClaim(CLAIM_SCOPE) == null) {
+ return new JWTAuthenticationResponse(
+ AuthCode.CLAIM_MISMATCH,
+ "Claim " + CLAIM_SCOPE + " is required but does not exist in JWT");
+ }
- // Determine roles of user, either from 'rolesClaim' or from 'scope' as parsed above
- final Set finalRoles = new HashSet<>();
- if (rolesClaim == null) {
- // Pass scopes with principal to signal to any Authorization plugins that user has
- // some verified role claims
- finalRoles.addAll(scopes);
- finalRoles.remove("openid"); // Remove standard scope
- } else {
- // Pull roles from separate claim, either as whitespace separated list or as JSON
- // array
- Object rolesObj = jwtClaims.getClaimValue(rolesClaim);
- if (rolesObj == null && rolesClaim.indexOf('.') > 0) {
- // support map resolution of nested values
- String[] nestedKeys = rolesClaim.split("\\.");
- rolesObj = jwtClaims.getClaimValue(nestedKeys[0]);
- for (int i = 1; i < nestedKeys.length; i++) {
- if (rolesObj instanceof Map) {
- String key = nestedKeys[i];
- rolesObj = ((Map, ?>) rolesObj).get(key);
- }
- }
+ // Find scopes for user
+ Set scopes = Set.of();
+ Object scopesObj = jwtClaims.getClaim(CLAIM_SCOPE);
+ if (scopesObj != null) {
+ if (scopesObj instanceof String) {
+ scopes = new HashSet<>(Arrays.asList(((String) scopesObj).split("\\s+")));
+ } else if (scopesObj instanceof List) {
+ scopes = new HashSet<>(jwtClaims.getStringListClaim(CLAIM_SCOPE));
+ }
+ // Validate that at least one of the required scopes are present in the scope claim
+ if (!requiredScopes.isEmpty()) {
+ if (scopes.stream().noneMatch(requiredScopes::contains)) {
+ return new JWTAuthenticationResponse(
+ AuthCode.SCOPE_MISSING,
+ "Claim "
+ + CLAIM_SCOPE
+ + " does not contain any of the required scopes: "
+ + requiredScopes);
}
+ }
+ }
- if (rolesObj != null) {
- if (rolesObj instanceof String) {
- finalRoles.addAll(Arrays.asList(((String) rolesObj).split("\\s+")));
- } else if (rolesObj instanceof List) {
- ((List>) rolesObj)
- .forEach(
- entry -> {
- if (entry instanceof String) {
- finalRoles.add((String) entry);
- } else {
- throw new SolrException(
- SolrException.ErrorCode.BAD_REQUEST,
- String.format(
- Locale.ROOT,
- "Could not parse roles from JWT claim %s; expected array of strings, got array with a value of type %s",
- rolesClaim,
- entry.getClass().getSimpleName()));
- }
- });
- } else {
- throw new SolrException(
- SolrException.ErrorCode.BAD_REQUEST,
- String.format(
- Locale.ROOT,
- "Could not parse roles from JWT claim %s; got %s",
- rolesClaim,
- rolesObj.getClass().getSimpleName()));
+ // Determine roles of user, either from 'rolesClaim' or from 'scope' as parsed above
+ final Set finalRoles = new HashSet<>();
+ if (rolesClaim == null) {
+ finalRoles.addAll(scopes);
+ finalRoles.remove("openid"); // Remove standard scope
+ } else {
+ Object rolesObj = jwtClaims.getClaim(rolesClaim);
+ if (rolesObj == null && rolesClaim.indexOf('.') > 0) {
+ // support map resolution of nested values
+ String[] nestedKeys = rolesClaim.split("\\.");
+ rolesObj = jwtClaims.getClaim(nestedKeys[0]);
+ for (int i = 1; i < nestedKeys.length; i++) {
+ if (rolesObj instanceof Map) {
+ String key = nestedKeys[i];
+ rolesObj = ((Map, ?>) rolesObj).get(key);
}
}
}
- if (!finalRoles.isEmpty()) {
- return new JWTAuthenticationResponse(
- AuthCode.AUTHENTICATED,
- new JWTPrincipalWithUserRoles(
- principal, jwtCompact, jwtClaims.getClaimsMap(), finalRoles));
- } else {
- return new JWTAuthenticationResponse(
- AuthCode.AUTHENTICATED,
- new JWTPrincipal(principal, jwtCompact, jwtClaims.getClaimsMap()));
- }
- } catch (InvalidJwtSignatureException ise) {
- return new JWTAuthenticationResponse(AuthCode.SIGNATURE_INVALID, ise);
- } catch (InvalidJwtException e) {
- // Whether or not the JWT has expired being one common reason for invalidity
- if (e.hasExpired()) {
- return new JWTAuthenticationResponse(
- AuthCode.JWT_EXPIRED,
- "Authentication failed due to expired JWT token. Expired at "
- + e.getJwtContext().getJwtClaims().getExpirationTime());
- }
- if (e.getCause() != null
- && e.getCause() instanceof JoseException
- && e.getCause().getMessage().contains("Invalid JOSE Compact Serialization")) {
- return new JWTAuthenticationResponse(
- AuthCode.JWT_PARSE_ERROR, e.getCause().getMessage());
+
+ if (rolesObj != null) {
+ if (rolesObj instanceof String) {
+ finalRoles.addAll(Arrays.asList(((String) rolesObj).split("\\s+")));
+ } else if (rolesObj instanceof List) {
+ ((List>) rolesObj)
+ .forEach(
+ entry -> {
+ if (entry instanceof String) {
+ finalRoles.add((String) entry);
+ } else {
+ throw new SolrException(
+ SolrException.ErrorCode.BAD_REQUEST,
+ String.format(
+ Locale.ROOT,
+ "Could not parse roles from JWT claim %s; expected array of strings, got array with a value of type %s",
+ rolesClaim,
+ entry.getClass().getSimpleName()));
+ }
+ });
+ } else {
+ throw new SolrException(
+ SolrException.ErrorCode.BAD_REQUEST,
+ String.format(
+ Locale.ROOT,
+ "Could not parse roles from JWT claim %s; got %s",
+ rolesClaim,
+ rolesObj.getClass().getSimpleName()));
+ }
}
- return new JWTAuthenticationResponse(AuthCode.JWT_VALIDATION_EXCEPTION, e);
}
- } catch (MalformedClaimException e) {
+ if (!finalRoles.isEmpty()) {
+ return new JWTAuthenticationResponse(
+ AuthCode.AUTHENTICATED,
+ new JWTPrincipalWithUserRoles(
+ principal, jwtCompact, jwtClaims.getClaims(), finalRoles));
+ } else {
+ return new JWTAuthenticationResponse(
+ AuthCode.AUTHENTICATED,
+ new JWTPrincipal(principal, jwtCompact, jwtClaims.getClaims()));
+ }
+ } catch (ParseException e) {
return new JWTAuthenticationResponse(
AuthCode.JWT_PARSE_ERROR, "Malformed claim, error was: " + e.getMessage());
}
@@ -736,43 +785,37 @@ private String parseAuthorizationHeader(String authorizationHeader) {
}
private void initConsumer() {
- JwtConsumerBuilder jwtConsumerBuilder =
- new JwtConsumerBuilder()
- .setAllowedClockSkewInSeconds(
- 30); // allow some leeway in validating time based claims to account for clock skew
- String[] issuers =
- issuerConfigs.stream()
- .map(JWTIssuerConfig::getIss)
- .filter(Objects::nonNull)
- .toArray(String[]::new);
- if (issuers.length > 0) {
- jwtConsumerBuilder.setExpectedIssuers(
- requireIssuer, issuers); // whom the JWT needs to have been issued by
+ DefaultJWTProcessor processor = new DefaultJWTProcessor<>();
+ processor.setJWSKeySelector(verificationKeyResolver);
+
+ // Build the set of required claims
+ Set requiredClaims = new HashSet<>();
+ if (requireExpirationTime) {
+ requiredClaims.add("exp");
+ }
+ if (requireIssuer) {
+ requiredClaims.add("iss");
}
- String[] audiences =
+
+ // Build expected audiences
+ Set audienceSet =
issuerConfigs.stream()
.map(JWTIssuerConfig::getAud)
.filter(Objects::nonNull)
- .toArray(String[]::new);
- if (audiences.length > 0) {
- jwtConsumerBuilder.setExpectedAudience(audiences); // to whom the JWT is intended for
- } else {
- jwtConsumerBuilder.setSkipDefaultAudienceValidation();
- }
- if (requireExpirationTime) jwtConsumerBuilder.setRequireExpirationTime();
- if (algAllowlist != null)
- jwtConsumerBuilder
- .setJwsAlgorithmConstraints( // only allow the expected signature algorithm(s) in the
- // given context
- new AlgorithmConstraints(
- AlgorithmConstraints.ConstraintType.PERMIT, algAllowlist.toArray(new String[0])));
- jwtConsumerBuilder.setVerificationKeyResolver(verificationKeyResolver);
- jwtConsumer = jwtConsumerBuilder.build(); // create the JwtConsumer instance
+ .collect(Collectors.toSet());
+
+ DefaultJWTClaimsVerifier verifier =
+ new DefaultJWTClaimsVerifier<>(
+ audienceSet.isEmpty() ? null : audienceSet, null, requiredClaims, Set.of());
+ verifier.setMaxClockSkew(30);
+ processor.setJWTClaimsSetVerifier(verifier);
+
+ jwtProcessor = processor;
}
@Override
public void close() throws IOException {
- jwtConsumer = null;
+ jwtProcessor = null;
super.close();
}
@@ -867,7 +910,7 @@ protected static class JWTAuthenticationResponse {
private final Principal principal;
private String errorMessage;
private final AuthCode authCode;
- private InvalidJwtException jwtException;
+ private Exception jwtException;
enum AuthCode {
PASS_THROUGH(
@@ -897,7 +940,7 @@ public String getMsg() {
}
}
- JWTAuthenticationResponse(AuthCode authCode, InvalidJwtException e) {
+ JWTAuthenticationResponse(AuthCode authCode, Exception e) {
this.authCode = authCode;
this.jwtException = e;
principal = null;
@@ -932,7 +975,7 @@ String getErrorMessage() {
return errorMessage;
}
- InvalidJwtException getJwtException() {
+ Exception getJwtException() {
return jwtException;
}
diff --git a/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTIssuerConfig.java b/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTIssuerConfig.java
index d8a6934317a2..652936f558a9 100644
--- a/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTIssuerConfig.java
+++ b/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTIssuerConfig.java
@@ -18,6 +18,11 @@
package org.apache.solr.security.jwt;
import com.google.common.annotations.VisibleForTesting;
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.util.DefaultResourceRetriever;
+import com.nimbusds.jose.util.Resource;
+import com.nimbusds.jose.util.ResourceRetriever;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
@@ -26,9 +31,14 @@
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
+import java.net.URLConnection;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.KeyStore;
import java.security.cert.X509Certificate;
+import java.text.ParseException;
+import java.time.Instant;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
@@ -36,16 +46,14 @@
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
+import javax.net.ssl.HttpsURLConnection;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLSocketFactory;
+import javax.net.ssl.TrustManagerFactory;
import org.apache.solr.common.SolrException;
import org.apache.solr.common.util.EnvUtils;
import org.apache.solr.common.util.StrUtils;
import org.apache.solr.common.util.Utils;
-import org.jose4j.http.Get;
-import org.jose4j.http.SimpleResponse;
-import org.jose4j.jwk.HttpsJwks;
-import org.jose4j.jwk.JsonWebKey;
-import org.jose4j.jwk.JsonWebKeySet;
-import org.jose4j.lang.JoseException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -66,10 +74,10 @@ public class JWTIssuerConfig {
private static HttpsJwksFactory httpsJwksFactory = new HttpsJwksFactory(3600, 5000);
private String iss;
private String aud;
- private JsonWebKeySet jsonWebKeySet;
+ private JWKSet jsonWebKeySet;
private String name;
private List jwksUrl;
- private List httpsJwks;
+ private List httpsJwks;
private String wellKnownUrl;
private WellKnownDiscoveryConfig wellKnownDiscoveryConfig;
private String clientId;
@@ -183,7 +191,7 @@ protected void parseConfigMap(Map configMap) {
}
/**
- * Setter that takes a jwk config object, parses it into a {@link JsonWebKeySet} and sets it
+ * Setter that takes a jwk config object, parses it into a {@link JWKSet} and sets it
*
* @param jwksObject the config object to parse
*/
@@ -193,7 +201,7 @@ protected void setJsonWebKeySet(Object jwksObject) {
if (jwksObject != null) {
jsonWebKeySet = parseJwkSet((Map) jwksObject);
}
- } catch (JoseException e) {
+ } catch (ParseException e) {
throw new SolrException(
SolrException.ErrorCode.SERVER_ERROR,
"Failed parsing parameter 'jwk' for issuer " + getName(),
@@ -202,17 +210,13 @@ protected void setJsonWebKeySet(Object jwksObject) {
}
@SuppressWarnings("unchecked")
- protected static JsonWebKeySet parseJwkSet(Map jwkObj) throws JoseException {
- JsonWebKeySet webKeySet = new JsonWebKeySet();
+ protected static JWKSet parseJwkSet(Map jwkObj) throws ParseException {
+ String json = Utils.toJSONString(jwkObj);
if (jwkObj.containsKey("keys")) {
- List