From 4af601eb10cea9244800a296cc2fd578dd290774 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20H=C3=B8ydahl?= Date: Tue, 10 Sep 2019 13:01:47 +0200 Subject: [PATCH] SOLR-13713: JWTAuthPlugin to support multiple JWKS endpoints (cherry picked from commit 4599f6e9ee2a647c1d6861adfedb12e5cf74783d) --- solr/CHANGES.txt | 2 + .../apache/solr/security/JWTAuthPlugin.java | 153 +++++++++++++---- .../security/JWTVerificationkeyResolver.java | 113 +++++++++++++ .../solr/security/JWTAuthPluginTest.java | 94 ++--------- .../JWTVerificationkeyResolverTest.java | 156 ++++++++++++++++++ .../src/jwt-authentication-plugin.adoc | 2 +- 6 files changed, 407 insertions(+), 113 deletions(-) create mode 100644 solr/core/src/java/org/apache/solr/security/JWTVerificationkeyResolver.java create mode 100644 solr/core/src/test/org/apache/solr/security/JWTVerificationkeyResolverTest.java diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt index 3b48212df63..716d8734680 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 c5ba67c6bc2..9d0d44aab48 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 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 requiredScopes = new ArrayList<>(); private String clientId; - private long jwkCacheDuration; private WellKnownDiscoveryConfig oidcDiscoveryConfig; private String confIdpConfigUrl; private Map pluginConfig; @@ -128,7 +127,7 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider, private String authorizationEndpoint; private String adminUiScope; private List redirectUris; - private HttpsJwks httpsJkws; + private IssuerConfig issuerConfig; /** @@ -226,9 +225,9 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider, @SuppressWarnings("unchecked") private void initJwk(Map pluginConfig) { this.pluginConfig = pluginConfig; - String confJwkUrl = (String) pluginConfig.get(PARAM_JWK_URL); + Object confJwkUrl = pluginConfig.get(PARAM_JWK_URL); Map confJwk = (Map) 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 urls = (confJwkUrl instanceof List) ? (List)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 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 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 jwksUrl; + private List httpsJwks; + + /** + * Create config + * @param iss unique issuer id string + * @param jwksUrls list of URLs for JWKs endpoints + */ + public IssuerConfig(String iss, List 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 getJwksUrl() { + return jwksUrl; + } + + public List 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) { + 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 createList(List 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 00000000000..09b33d493e0 --- /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 nestingContext) throws UnresolvableKeyException { + JsonWebKey theChosenOne; + List 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 407c9b0a6fe..ad04fc800ba 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 testConfig; private HashMap 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 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 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 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 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 00000000000..d4660c57013 --- /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 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) 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) 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 4993149b19c..f2c9c51e2bc 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