You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@lucene.apache.org by ja...@apache.org on 2019/09/10 11:17:45 UTC
[lucene-solr] branch branch_8x updated: SOLR-13713: JWTAuthPlugin
to support multiple JWKS endpoints
This is an automated email from the ASF dual-hosted git repository.
janhoy pushed a commit to branch branch_8x
in repository https://gitbox.apache.org/repos/asf/lucene-solr.git
The following commit(s) were added to refs/heads/branch_8x by this push:
new 4af601e SOLR-13713: JWTAuthPlugin to support multiple JWKS endpoints
4af601e is described below
commit 4af601eb10cea9244800a296cc2fd578dd290774
Author: Jan Høydahl <ja...@apache.org>
AuthorDate: Tue Sep 10 13:01:47 2019 +0200
SOLR-13713: JWTAuthPlugin to support multiple JWKS endpoints
(cherry picked from commit 4599f6e9ee2a647c1d6861adfedb12e5cf74783d)
---
solr/CHANGES.txt | 2 +
.../org/apache/solr/security/JWTAuthPlugin.java | 153 ++++++++++++++++----
.../solr/security/JWTVerificationkeyResolver.java | 113 +++++++++++++++
.../apache/solr/security/JWTAuthPluginTest.java | 94 ++-----------
.../security/JWTVerificationkeyResolverTest.java | 156 +++++++++++++++++++++
.../src/jwt-authentication-plugin.adoc | 2 +-
6 files changed, 407 insertions(+), 113 deletions(-)
diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt
index 3b48212..716d873 100644
--- a/solr/CHANGES.txt
+++ b/solr/CHANGES.txt
@@ -71,6 +71,8 @@ New Features
* SOLR-13122: Ability to query aliases in Solr Admin UI (janhoy)
+* SOLR-13713: JWTAuthPlugin to support multiple JWKS endpoints (janhoy)
+
Improvements
----------------------
diff --git a/solr/core/src/java/org/apache/solr/security/JWTAuthPlugin.java b/solr/core/src/java/org/apache/solr/security/JWTAuthPlugin.java
index c5ba67c..9d0d44a 100644
--- a/solr/core/src/java/org/apache/solr/security/JWTAuthPlugin.java
+++ b/solr/core/src/java/org/apache/solr/security/JWTAuthPlugin.java
@@ -69,7 +69,6 @@ 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.keys.resolvers.HttpsJwksVerificationKeyResolver;
import org.jose4j.keys.resolvers.JwksVerificationKeyResolver;
import org.jose4j.keys.resolvers.VerificationKeyResolver;
import org.jose4j.lang.JoseException;
@@ -102,6 +101,7 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider,
private static final String AUTH_REALM = "solr-jwt";
private static final String CLAIM_SCOPE = "scope";
private static final long RETRY_INIT_DELAY_SECONDS = 30;
+ private static final long DEFAULT_REFRESH_REPRIEVE_THRESHOLD = 5000;
private static final Set<String> PROPS = ImmutableSet.of(PARAM_BLOCK_UNKNOWN, PARAM_JWK_URL, PARAM_JWK, PARAM_ISSUER,
PARAM_AUDIENCE, PARAM_REQUIRE_SUBJECT, PARAM_PRINCIPAL_CLAIM, PARAM_REQUIRE_EXPIRATIONTIME, PARAM_ALG_WHITELIST,
@@ -120,7 +120,6 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider,
private boolean blockUnknown;
private List<String> requiredScopes = new ArrayList<>();
private String clientId;
- private long jwkCacheDuration;
private WellKnownDiscoveryConfig oidcDiscoveryConfig;
private String confIdpConfigUrl;
private Map<String, Object> pluginConfig;
@@ -128,7 +127,7 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider,
private String authorizationEndpoint;
private String adminUiScope;
private List<String> redirectUris;
- private HttpsJwks httpsJkws;
+ private IssuerConfig issuerConfig;
/**
@@ -226,9 +225,9 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider,
@SuppressWarnings("unchecked")
private void initJwk(Map<String, Object> pluginConfig) {
this.pluginConfig = pluginConfig;
- String confJwkUrl = (String) pluginConfig.get(PARAM_JWK_URL);
+ Object confJwkUrl = pluginConfig.get(PARAM_JWK_URL);
Map<String, Object> confJwk = (Map<String, Object>) pluginConfig.get(PARAM_JWK);
- jwkCacheDuration = Long.parseLong((String) pluginConfig.getOrDefault(PARAM_JWK_CACHE_DURATION, "3600"));
+ long jwkCacheDuration = Long.parseLong((String) pluginConfig.getOrDefault(PARAM_JWK_CACHE_DURATION, "3600"));
jwtConsumer = null;
int jwkConfigured = confIdpConfigUrl != null ? 1 : 0;
@@ -241,40 +240,35 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider,
if (jwkConfigured == 0) {
log.warn("Initialized JWTAuthPlugin without any JWK config. Requests with jwk header will fail.");
}
- if (oidcDiscoveryConfig != null) {
- String jwkUrl = oidcDiscoveryConfig.getJwksUrl();
- setupJwkUrl(jwkUrl);
- } else if (confJwkUrl != null) {
- setupJwkUrl(confJwkUrl);
+
+ HttpsJwksFactory httpsJwksFactory = new HttpsJwksFactory(jwkCacheDuration, DEFAULT_REFRESH_REPRIEVE_THRESHOLD);
+ if (confJwkUrl != null) {
+ try {
+ List<String> urls = (confJwkUrl instanceof List) ? (List<String>)confJwkUrl : Collections.singletonList((String) confJwkUrl);
+ issuerConfig = new IssuerConfig(iss, urls);
+ issuerConfig.setHttpsJwksFactory(httpsJwksFactory);
+ verificationKeyResolver = new JWTVerificationkeyResolver(issuerConfig);
+ } catch (ClassCastException e) {
+ throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Parameter " + PARAM_JWK_URL + " must be either List or String");
+ }
} else if (confJwk != null) {
try {
JsonWebKeySet jwks = parseJwkSet(confJwk);
+ issuerConfig = new IssuerConfig(iss, jwks);
verificationKeyResolver = new JwksVerificationKeyResolver(jwks.getJsonWebKeys());
- httpsJkws = null;
} catch (JoseException e) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Invalid JWTAuthPlugin configuration, " + PARAM_JWK + " parse error", e);
}
+ } else if (oidcDiscoveryConfig != null) {
+ List<String> urls = Collections.singletonList(oidcDiscoveryConfig.getJwksUrl());
+ issuerConfig = new IssuerConfig(iss, urls);
+ issuerConfig.setHttpsJwksFactory(httpsJwksFactory);
+ verificationKeyResolver = new JWTVerificationkeyResolver(issuerConfig);
}
initConsumer();
log.debug("JWK configured");
}
- void setupJwkUrl(String url) {
- // The HttpsJwks retrieves and caches keys from a the given HTTPS JWKS endpoint.
- try {
- URL jwkUrl = new URL(url);
- if (!"https".equalsIgnoreCase(jwkUrl.getProtocol())) {
- throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, PARAM_JWK_URL + " must be an HTTPS url");
- }
- } catch (MalformedURLException e) {
- throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, PARAM_JWK_URL + " must be a valid URL");
- }
- httpsJkws = new HttpsJwks(url);
- httpsJkws.setDefaultCacheDuration(jwkCacheDuration);
- httpsJkws.setRefreshReprieveThreshold(5000);
- verificationKeyResolver = new HttpsJwksVerificationKeyResolver(httpsJkws);
- }
-
@SuppressWarnings("unchecked")
JsonWebKeySet parseJwkSet(Map<String, Object> jwkObj) throws JoseException {
JsonWebKeySet webKeySet = new JsonWebKeySet();
@@ -319,10 +313,12 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider,
}
JWTAuthenticationResponse authResponse = authenticate(header);
- if (AuthCode.SIGNATURE_INVALID.equals(authResponse.getAuthCode()) && httpsJkws != null) {
+ if (AuthCode.SIGNATURE_INVALID.equals(authResponse.getAuthCode()) && issuerConfig.usesHttpsJwk()) {
log.warn("Signature validation failed. Refreshing JWKs from IdP before trying again: {}",
authResponse.getJwtException() == null ? "" : authResponse.getJwtException().getMessage());
- httpsJkws.refresh();
+ for (HttpsJwks httpsJwks : issuerConfig.getHttpsJwks()) {
+ httpsJwks.refresh();
+ }
authResponse = authenticate(header);
}
String exceptionMessage = authResponse.getJwtException() != null ? authResponse.getJwtException().getMessage() : "";
@@ -563,7 +559,6 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider,
return Base64.byteArrayToBase64(headerJson.getBytes(StandardCharsets.UTF_8));
}
-
/**
* Response for authentication attempt
*/
@@ -708,6 +703,104 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider,
}
}
+ /**
+ * Holds information about an IdP (issuer), such as issuer ID, JWK url(s), keys etc
+ */
+ public static class IssuerConfig {
+ private HttpsJwksFactory httpsJwksFactory;
+ private String iss;
+ private JsonWebKeySet jsonWebKeySet;
+ private List<String> jwksUrl;
+ private List<HttpsJwks> httpsJwks;
+
+ /**
+ * Create config
+ * @param iss unique issuer id string
+ * @param jwksUrls list of URLs for JWKs endpoints
+ */
+ public IssuerConfig(String iss, List<String> jwksUrls) {
+ this.jwksUrl = jwksUrls;
+ this.iss = iss;
+ }
+
+ public IssuerConfig(String iss, JsonWebKeySet jsonWebKeySet) {
+ this.iss = iss;
+ this.jsonWebKeySet = jsonWebKeySet;
+ }
+
+ public String getIss() {
+ return iss;
+ }
+
+ public List<String> getJwksUrl() {
+ return jwksUrl;
+ }
+
+ public List<HttpsJwks> getHttpsJwks() {
+ if (httpsJwks == null) {
+ if (httpsJwksFactory == null) {
+ httpsJwksFactory = new HttpsJwksFactory(3600, DEFAULT_REFRESH_REPRIEVE_THRESHOLD);
+ log.warn("Created HttpsJwksFactory with default cache duration and reprieveThreshold");
+ }
+ httpsJwks = httpsJwksFactory.createList(getJwksUrl());
+ }
+ return httpsJwks;
+ }
+
+ public void setHttpsJwks(List<HttpsJwks> httpsJwks) {
+ this.httpsJwks = httpsJwks;
+ }
+
+ /**
+ * Set the factory to use when creating HttpsJwks objects
+ * @param httpsJwksFactory factory with custom settings
+ */
+ public void setHttpsJwksFactory(HttpsJwksFactory httpsJwksFactory) {
+ this.httpsJwksFactory = httpsJwksFactory;
+ }
+
+ public JsonWebKeySet getJsonWebKeySet() {
+ return jsonWebKeySet;
+ }
+
+ /**
+ * Check if the issuer is backed by HttpsJwk url(s)
+ * @return true if keys are fetched over https
+ */
+ public boolean usesHttpsJwk() {
+ return getJwksUrl() != null && !getJwksUrl().isEmpty();
+ }
+ }
+
+ public static class HttpsJwksFactory {
+ private final long jwkCacheDuration;
+ private final long refreshReprieveThreshold;
+
+ public HttpsJwksFactory(long jwkCacheDuration, long refreshReprieveThreshold) {
+ this.jwkCacheDuration = jwkCacheDuration;
+ this.refreshReprieveThreshold = refreshReprieveThreshold;
+ }
+
+ public HttpsJwks create(String url) {
+ try {
+ URL jwkUrl = new URL(url);
+ if (!"https".equalsIgnoreCase(jwkUrl.getProtocol())) {
+ throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, PARAM_JWK_URL + " must use HTTPS");
+ }
+ } catch (MalformedURLException e) {
+ throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Url " + url + " configured in " + PARAM_JWK_URL + " is not a valid URL");
+ }
+ HttpsJwks httpsJkws = new HttpsJwks(url);
+ httpsJkws.setDefaultCacheDuration(jwkCacheDuration);
+ httpsJkws.setRefreshReprieveThreshold(refreshReprieveThreshold);
+ return httpsJkws;
+ }
+
+ public List<HttpsJwks> createList(List<String> jwkUrls) {
+ return jwkUrls.stream().map(this::create).collect(Collectors.toList());
+ }
+ }
+
@Override
protected boolean interceptInternodeRequest(HttpRequest httpRequest, HttpContext httpContext) {
if (httpContext instanceof HttpClientContext) {
diff --git a/solr/core/src/java/org/apache/solr/security/JWTVerificationkeyResolver.java b/solr/core/src/java/org/apache/solr/security/JWTVerificationkeyResolver.java
new file mode 100644
index 0000000..09b33d4
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/security/JWTVerificationkeyResolver.java
@@ -0,0 +1,113 @@
+/*
+ * 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;
+
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.security.Key;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.solr.security.JWTAuthPlugin.IssuerConfig;
+import org.jose4j.jwk.HttpsJwks;
+import org.jose4j.jwk.JsonWebKey;
+import org.jose4j.jwk.VerificationJwkSelector;
+import org.jose4j.jws.JsonWebSignature;
+import org.jose4j.jwx.JsonWebStructure;
+import org.jose4j.keys.resolvers.VerificationKeyResolver;
+import org.jose4j.lang.JoseException;
+import org.jose4j.lang.UnresolvableKeyException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Adaption of {@link org.jose4j.keys.resolvers.HttpsJwksVerificationKeyResolver} to resolve
+ * keys from multiple HttpsJwks endpoints, which is sometimes necessary if the IdP
+ * does not publish all public keys that may have signed a token through the main JWKs endpoint.
+ * Such setups typically have support for multiple signing backends, each serving its own JWKs
+ * endpoint for its keys.
+ *
+ * This implementation collects all keys from all endpoints into a single list and
+ * the rest of the implementation is equivalent to that of HttpsJwksVerificationKeyResolver.
+ *
+ * No attempt is made to keep track of which key came from which JWKs endpoint, and if a
+ * key is not found in any cache, all JWKs endpoints are refreshed before a single retry.
+ *
+ * NOTE: This class can subclass HttpsJwksVerificationKeyResolver once a new version of jose4j is available
+ */
+public class JWTVerificationkeyResolver implements VerificationKeyResolver {
+ private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+ private VerificationJwkSelector verificationJwkSelector = new VerificationJwkSelector();
+
+ private IssuerConfig issuerConfig;
+
+ /**
+ * Resolves key from a list of JWKs URLs stored in IssuerConfig
+ * @param issuerConfig Configuration object for the issuer
+ */
+ public JWTVerificationkeyResolver(IssuerConfig issuerConfig) {
+ this.issuerConfig = issuerConfig;
+ assert(issuerConfig.usesHttpsJwk());
+ }
+
+ @Override
+ public Key resolveKey(JsonWebSignature jws, List<JsonWebStructure> nestingContext) throws UnresolvableKeyException {
+ JsonWebKey theChosenOne;
+ List<JsonWebKey> jsonWebKeys = new ArrayList<>();
+
+
+ try {
+ // Add all keys into a master list
+ for (HttpsJwks hjwks : issuerConfig.getHttpsJwks()) {
+ jsonWebKeys.addAll(hjwks.getJsonWebKeys());
+ }
+
+ theChosenOne = verificationJwkSelector.select(jws, jsonWebKeys);
+ if (theChosenOne == null) {
+ log.debug("Refreshing JWKs from all {} locations, as no suitable verification key for JWS w/ header {} was found in {}",
+ issuerConfig.getHttpsJwks().size(), jws.getHeaders().getFullHeaderAsJsonString(), jsonWebKeys);
+
+ jsonWebKeys.clear();
+ for (HttpsJwks hjwks : issuerConfig.getHttpsJwks()) {
+ hjwks.refresh();
+ jsonWebKeys.addAll(hjwks.getJsonWebKeys());
+ }
+ theChosenOne = verificationJwkSelector.select(jws, jsonWebKeys);
+ }
+ } catch (JoseException | IOException e) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("Unable to find a suitable verification key for JWS w/ header ").append(jws.getHeaders().getFullHeaderAsJsonString());
+ sb.append(" due to an unexpected exception (").append(e).append(") while obtaining or using keys from JWKS endpoints at ");
+ sb.append(issuerConfig.getJwksUrl());
+ throw new UnresolvableKeyException(sb.toString(), e);
+ }
+
+ if (theChosenOne == null) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("Unable to find a suitable verification key for JWS w/ header ").append(jws.getHeaders().getFullHeaderAsJsonString());
+ sb.append(" from JWKs ").append(jsonWebKeys).append(" obtained from ").append(issuerConfig.getJwksUrl());
+ throw new UnresolvableKeyException(sb.toString());
+ }
+
+ return theChosenOne.getKey();
+ }
+
+ IssuerConfig getIssuerConfig() {
+ return issuerConfig;
+ }
+}
diff --git a/solr/core/src/test/org/apache/solr/security/JWTAuthPluginTest.java b/solr/core/src/test/org/apache/solr/security/JWTAuthPluginTest.java
index 407c9b0..ad04fc8 100644
--- a/solr/core/src/test/org/apache/solr/security/JWTAuthPluginTest.java
+++ b/solr/core/src/test/org/apache/solr/security/JWTAuthPluginTest.java
@@ -24,7 +24,6 @@ import java.nio.file.Path;
import java.security.Principal;
import java.util.ArrayList;
import java.util.Arrays;
-import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -35,16 +34,12 @@ import org.apache.solr.SolrTestCaseJ4;
import org.apache.solr.common.SolrException;
import org.apache.solr.common.util.Base64;
import org.apache.solr.common.util.Utils;
-import org.jose4j.jwk.HttpsJwks;
-import org.jose4j.jwk.JsonWebKey;
import org.jose4j.jwk.RsaJsonWebKey;
import org.jose4j.jwk.RsaJwkGenerator;
import org.jose4j.jws.AlgorithmIdentifiers;
import org.jose4j.jws.JsonWebSignature;
import org.jose4j.jwt.JwtClaims;
import org.jose4j.keys.BigEndianBigInteger;
-import org.jose4j.keys.resolvers.HttpsJwksVerificationKeyResolver;
-import org.jose4j.lang.JoseException;
import org.junit.After;
import org.junit.Before;
import org.junit.BeforeClass;
@@ -64,7 +59,6 @@ public class JWTAuthPluginTest extends SolrTestCaseJ4 {
private HashMap<String, Object> testConfig;
private HashMap<String, Object> minimalConfig;
-
@BeforeClass
public static void beforeAll() throws Exception {
// Generate an RSA key pair, which will be used for signing and verification of the JWT, wrapped in a JWK
@@ -89,7 +83,7 @@ public class JWTAuthPluginTest extends SolrTestCaseJ4 {
slimHeader = "Bearer" + " " + slimJwt;
}
- static JwtClaims generateClaims() {
+ protected static JwtClaims generateClaims() {
JwtClaims claims = new JwtClaims();
claims.setIssuer("IDServer"); // who creates the token and signs it
claims.setAudience("Solr"); // to whom the token is intended to be sent
@@ -112,10 +106,12 @@ public class JWTAuthPluginTest extends SolrTestCaseJ4 {
@Before
public void setUp() throws Exception {
super.setUp();
+
// Create an auth plugin
plugin = new JWTAuthPlugin();
// Create a JWK config for security.json
+
testJwk = new HashMap<>();
testJwk.put("kty", rsaJsonWebKey.getKeyType());
testJwk.put("e", BigEndianBigInteger.toBase64Url(rsaJsonWebKey.getRsaPublicKey().getPublicExponent()));
@@ -185,39 +181,18 @@ public class JWTAuthPluginTest extends SolrTestCaseJ4 {
authConf.put("jwkUrl", "https://127.0.0.1:9999/foo.jwk");
plugin = new JWTAuthPlugin();
plugin.init(authConf);
+ JWTVerificationkeyResolver resolver = (JWTVerificationkeyResolver) plugin.verificationKeyResolver;
+ assertEquals(1, resolver.getIssuerConfig().getJwksUrl().size());
}
- /**
- * Simulate a rotate of JWK key in IdP.
- * Validating of JWK signature will fail since we still use old cached JWK set.
- * Using a mock {@link HttpsJwks} we validate that plugin calls refresh() and then passes validation
- */
@Test
- public void invalidSigRefreshJwk() throws JoseException {
- RsaJsonWebKey rsaJsonWebKey2 = RsaJwkGenerator.generateJwk(2048);
- rsaJsonWebKey2.setKeyId("k2");
- HashMap<String, Object> testJwkWrong = new HashMap<>();
- testJwkWrong.put("kty", rsaJsonWebKey2.getKeyType());
- testJwkWrong.put("e", BigEndianBigInteger.toBase64Url(rsaJsonWebKey2.getRsaPublicKey().getPublicExponent()));
- testJwkWrong.put("use", rsaJsonWebKey2.getUse());
- testJwkWrong.put("kid", rsaJsonWebKey2.getKeyId());
- testJwkWrong.put("alg", rsaJsonWebKey2.getAlgorithm());
- testJwkWrong.put("n", BigEndianBigInteger.toBase64Url(rsaJsonWebKey2.getRsaPublicKey().getModulus()));
- JsonWebKey wrongJwk = JsonWebKey.Factory.newJwk(testJwkWrong);
-
- // Configure our mock plugin with URL as jwk source
- JsonWebKey correctJwk = JsonWebKey.Factory.newJwk(testJwk);
- plugin = new MockJwksUrlPlugin(wrongJwk, correctJwk);
- HashMap<String, Object> pluginConfigJwkUrl = new HashMap<>();
- pluginConfigJwkUrl.put("class", "org.apache.solr.security.JWTAuthPlugin");
- pluginConfigJwkUrl.put("jwkUrl", "dummy");
- plugin.init(pluginConfigJwkUrl);
-
- // Validate that plugin will call refresh() on invalid signature, then the call succeeds
- assertFalse(((MockJwksUrlPlugin)plugin).isRefreshCalled());
- JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(testHeader);
- assertTrue(resp.isAuthenticated());
- assertTrue(((MockJwksUrlPlugin)plugin).isRefreshCalled());
+ public void initWithJwkUrlArray() {
+ HashMap<String, Object> authConf = new HashMap<>();
+ authConf.put("jwkUrl", Arrays.asList("https://127.0.0.1:9999/foo.jwk", "https://127.0.0.1:9999/foo2.jwk"));
+ plugin = new JWTAuthPlugin();
+ plugin.init(authConf);
+ JWTVerificationkeyResolver resolver = (JWTVerificationkeyResolver) plugin.verificationKeyResolver;
+ assertEquals(2, resolver.getIssuerConfig().getJwksUrl().size());
}
@Test
@@ -444,49 +419,4 @@ public class JWTAuthPluginTest extends SolrTestCaseJ4 {
assertEquals("http://acmepaymentscorp/oauth/auz/authorize", parsed.get("authorizationEndpoint"));
assertEquals("solr-cluster", parsed.get("client_id"));
}
-
- /**
- * Mock plugin that simulates a {@link HttpsJwks} with cached JWK that returns
- * a different JWK after a call to refresh()
- */
- private class MockJwksUrlPlugin extends JWTAuthPlugin {
- private final JsonWebKey wrongJwk;
- private final JsonWebKey correctJwk;
-
- boolean isRefreshCalled() {
- return refreshCalled;
- }
-
- private boolean refreshCalled;
-
- MockJwksUrlPlugin(JsonWebKey wrongJwk, JsonWebKey correctJwk) {
- this.wrongJwk = wrongJwk;
- this.correctJwk = correctJwk;
- }
-
- @Override
- void setupJwkUrl(String url) {
- MockHttpsJwks httpsJkws = new MockHttpsJwks(url);
- verificationKeyResolver = new HttpsJwksVerificationKeyResolver(httpsJkws);
- }
-
- private class MockHttpsJwks extends HttpsJwks {
- MockHttpsJwks(String url) {
- super(url);
- }
-
- @Override
- public List<JsonWebKey> getJsonWebKeys() {
- return refreshCalled ? Collections.singletonList(correctJwk) : Collections.singletonList(wrongJwk);
- }
-
- @Override
- public void refresh() {
- if (refreshCalled) {
- fail("Refresh called twice");
- }
- refreshCalled = true;
- }
- }
- }
}
\ No newline at end of file
diff --git a/solr/core/src/test/org/apache/solr/security/JWTVerificationkeyResolverTest.java b/solr/core/src/test/org/apache/solr/security/JWTVerificationkeyResolverTest.java
new file mode 100644
index 0000000..d4660c5
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/security/JWTVerificationkeyResolverTest.java
@@ -0,0 +1,156 @@
+/*
+ * 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;
+
+import java.util.Iterator;
+import java.util.List;
+
+import org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.security.JWTAuthPlugin.HttpsJwksFactory;
+import org.apache.solr.security.JWTAuthPlugin.IssuerConfig;
+import org.jose4j.jwk.HttpsJwks;
+import org.jose4j.jwk.JsonWebKey;
+import org.jose4j.jwk.RsaJsonWebKey;
+import org.jose4j.jwk.RsaJwkGenerator;
+import org.jose4j.jws.AlgorithmIdentifiers;
+import org.jose4j.jws.JsonWebSignature;
+import org.jose4j.lang.JoseException;
+import org.jose4j.lang.UnresolvableKeyException;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+import static java.util.Arrays.asList;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests the multi jwks resolver that can fetch keys from multiple JWKs
+ */
+public class JWTVerificationkeyResolverTest extends SolrTestCaseJ4 {
+ private JWTVerificationkeyResolver resolver;
+
+ @Rule
+ public MockitoRule mockitoRule = MockitoJUnit.rule();
+
+ @Mock
+ private HttpsJwks firstJwkList;
+ @Mock
+ private HttpsJwks secondJwkList;
+ @Mock
+ private HttpsJwksFactory httpsJwksFactory;
+
+ private KeyHolder k1;
+ private KeyHolder k2;
+ private KeyHolder k3;
+ private KeyHolder k4;
+ private KeyHolder k5;
+ private List<JsonWebKey> keysToReturnFromSecondJwk;
+ private Iterator refreshSequenceForSecondJwk;
+
+ @Before
+ public void setUp() throws Exception {
+ super.setUp();
+ k1 = new KeyHolder("k1");
+ k2 = new KeyHolder("k2");
+ k3 = new KeyHolder("k3");
+ k4 = new KeyHolder("k4");
+ k5 = new KeyHolder("k5");
+
+ when(firstJwkList.getJsonWebKeys()).thenReturn(asList(k1.getJwk(), k2.getJwk()));
+ doAnswer(invocation -> {
+ keysToReturnFromSecondJwk = (List<JsonWebKey>) refreshSequenceForSecondJwk.next();
+ System.out.println("Refresh called, next to return is " + keysToReturnFromSecondJwk);
+ return null;
+ }).when(secondJwkList).refresh();
+ when(secondJwkList.getJsonWebKeys()).then(inv -> {
+ if (keysToReturnFromSecondJwk == null)
+ keysToReturnFromSecondJwk = (List<JsonWebKey>) refreshSequenceForSecondJwk.next();
+ return keysToReturnFromSecondJwk;
+ });
+ when(httpsJwksFactory.createList(anyList())).thenReturn(asList(firstJwkList, secondJwkList));
+
+ IssuerConfig issuerConfig = new IssuerConfig("foo", asList("url1", "url2"));
+ issuerConfig.setHttpsJwksFactory(httpsJwksFactory);
+ resolver = new JWTVerificationkeyResolver(issuerConfig);
+
+ assumeWorkingMockito();
+ }
+
+ @Test
+ public void findKeyFromFirstList() throws JoseException {
+ refreshSequenceForSecondJwk = asList(
+ asList(k3.getJwk(), k4.getJwk()),
+ asList(k5.getJwk())).iterator();
+ resolver.resolveKey(k1.getJws(), null);
+ resolver.resolveKey(k2.getJws(), null);
+ resolver.resolveKey(k3.getJws(), null);
+ resolver.resolveKey(k4.getJws(), null);
+ // Key k5 is not in cache, so a refresh will be done, which
+ resolver.resolveKey(k5.getJws(), null);
+ }
+
+ @Test(expected = UnresolvableKeyException.class)
+ public void notFoundKey() throws JoseException {
+ refreshSequenceForSecondJwk = asList(
+ asList(k3.getJwk()),
+ asList(k4.getJwk()),
+ asList(k5.getJwk())).iterator();
+ // Will not find key since first refresh returns k4, and we only try one refresh.
+ resolver.resolveKey(k5.getJws(), null);
+ }
+
+ public class KeyHolder {
+ private final RsaJsonWebKey key;
+ private final String kid;
+
+ public KeyHolder(String kid) throws JoseException {
+ key = generateKey(kid);
+ this.kid = kid;
+ }
+
+ public RsaJsonWebKey getRsaKey() {
+ return key;
+ }
+
+ public JsonWebKey getJwk() throws JoseException {
+ JsonWebKey jsonKey = JsonWebKey.Factory.newJwk(key.getRsaPublicKey());
+ jsonKey.setKeyId(kid);
+ return jsonKey;
+ }
+
+ public JsonWebSignature getJws() {
+ JsonWebSignature jws = new JsonWebSignature();
+ jws.setPayload(JWTAuthPluginTest.generateClaims().toJson());
+ jws.setKey(getRsaKey().getPrivateKey());
+ jws.setKeyIdHeaderValue(getRsaKey().getKeyId());
+ jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256);
+ return jws;
+ }
+
+ private RsaJsonWebKey generateKey(String kid) throws JoseException {
+ RsaJsonWebKey rsaJsonWebKey = RsaJwkGenerator.generateJwk(2048);
+ rsaJsonWebKey.setKeyId(kid);
+ return rsaJsonWebKey;
+ }
+ }
+}
\ No newline at end of file
diff --git a/solr/solr-ref-guide/src/jwt-authentication-plugin.adoc b/solr/solr-ref-guide/src/jwt-authentication-plugin.adoc
index 4993149..f2c9c51 100644
--- a/solr/solr-ref-guide/src/jwt-authentication-plugin.adoc
+++ b/solr/solr-ref-guide/src/jwt-authentication-plugin.adoc
@@ -46,7 +46,7 @@ wellKnownUrl ; URL to an https://openid.net/specs/openid-connect-discove
clientId ; Client identifier for use with OpenID Connect ; (no default value) Required to authenticate with Admin UI
realm ; Name of the authentication realm to echo back in HTTP 401 responses. Will also be displayed in Admin UI login page ; 'solr-jwt'
scope ; Whitespace separated list of valid scopes. If configured, the JWT access token MUST contain a `scope` claim with at least one of the listed scopes. Example: `solr:read solr:admin` ;
-jwkUrl ; An https URL to a https://tools.ietf.org/html/rfc7517[JWK] keys file. ; Auto configured if `wellKnownUrl` is provided
+jwkUrl ; A URL to a https://tools.ietf.org/html/rfc7517#section-5[JWKs] endpoint. Must use https protocol. Optionally an array of URLs in which case all public keys from all URLs will be consulted when validating signatures. ; Auto configured if `wellKnownUrl` is provided
jwk ; As an alternative to `jwkUrl` you may provide a JSON object here containing the public key(s) of the issuer. ;
iss ; Validates that the `iss` (issuer) claim equals this string ; Auto configured if `wellKnownUrl` is provided
aud ; Validates that the `aud` (audience) claim equals this string ; If `clientId` is configured, require `aud` to match it