| /* |
| * 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.InputStream; |
| import java.nio.charset.StandardCharsets; |
| import java.nio.file.Files; |
| import java.nio.file.Path; |
| import java.security.Principal; |
| import java.util.Arrays; |
| import java.util.Collections; |
| import java.util.HashMap; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| |
| 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.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.lang.JoseException; |
| import org.junit.After; |
| import org.junit.Before; |
| import org.junit.BeforeClass; |
| import org.junit.Test; |
| |
| import static org.apache.solr.security.JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.AUTZ_HEADER_PROBLEM; |
| import static org.apache.solr.security.JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.NO_AUTZ_HEADER; |
| import static org.apache.solr.security.JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.SCOPE_MISSING; |
| |
| @SuppressWarnings("unchecked") |
| public class JWTAuthPluginTest extends SolrTestCaseJ4 { |
| private static String testHeader; |
| private static String slimHeader; |
| private JWTAuthPlugin plugin; |
| private static RsaJsonWebKey rsaJsonWebKey; |
| private HashMap<String, Object> testConfig; |
| private HashMap<String, Object> minimalConfig; |
| |
| // Shared with other tests |
| static HashMap<String, Object> testJwk; |
| |
| static { |
| // Generate an RSA key pair, which will be used for signing and verification of the JWT, wrapped in a JWK |
| try { |
| rsaJsonWebKey = RsaJwkGenerator.generateJwk(2048); |
| rsaJsonWebKey.setKeyId("k1"); |
| |
| testJwk = new HashMap<>(); |
| testJwk.put("kty", rsaJsonWebKey.getKeyType()); |
| testJwk.put("e", BigEndianBigInteger.toBase64Url(rsaJsonWebKey.getRsaPublicKey().getPublicExponent())); |
| testJwk.put("use", rsaJsonWebKey.getUse()); |
| testJwk.put("kid", rsaJsonWebKey.getKeyId()); |
| testJwk.put("alg", rsaJsonWebKey.getAlgorithm()); |
| testJwk.put("n", BigEndianBigInteger.toBase64Url(rsaJsonWebKey.getRsaPublicKey().getModulus())); |
| } catch (JoseException e) { |
| fail("Failed static initialization: " + e.getMessage()); |
| } |
| } |
| |
| @BeforeClass |
| public static void beforeAll() throws Exception { |
| JwtClaims claims = generateClaims(); |
| JsonWebSignature jws = new JsonWebSignature(); |
| jws.setPayload(claims.toJson()); |
| jws.setKey(rsaJsonWebKey.getPrivateKey()); |
| jws.setKeyIdHeaderValue(rsaJsonWebKey.getKeyId()); |
| jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256); |
| |
| String testJwt = jws.getCompactSerialization(); |
| testHeader = "Bearer" + " " + testJwt; |
| |
| claims.unsetClaim("iss"); |
| claims.unsetClaim("aud"); |
| claims.unsetClaim("exp"); |
| claims.setSubject(null); |
| jws.setPayload(claims.toJson()); |
| String slimJwt = jws.getCompactSerialization(); |
| slimHeader = "Bearer" + " " + slimJwt; |
| } |
| |
| 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 |
| claims.setExpirationTimeMinutesInTheFuture(10); // time when the token will expire (10 minutes from now) |
| claims.setGeneratedJwtId(); // a unique identifier for the token |
| claims.setIssuedAtToNow(); // when the token was issued/created (now) |
| claims.setNotBeforeMinutesInThePast(2); // time before which the token is not yet valid (2 minutes ago) |
| claims.setSubject("solruser"); // the subject/principal is whom the token is about |
| claims.setStringClaim("scope", "solr:read"); |
| claims.setClaim("name", "Solr User"); // additional claims/attributes about the subject can be added |
| claims.setClaim("customPrincipal", "custom"); // additional claims/attributes about the subject can be added |
| claims.setClaim("claim1", "foo"); // additional claims/attributes about the subject can be added |
| claims.setClaim("claim2", "bar"); // additional claims/attributes about the subject can be added |
| claims.setClaim("claim3", "foo"); // additional claims/attributes about the subject can be added |
| List<String> roles = Arrays.asList("group-one", "other-group", "group-three"); |
| claims.setStringListClaim("roles", roles); // multi-valued claims work too and will end up as a JSON array |
| return claims; |
| } |
| |
| @Before |
| public void setUp() throws Exception { |
| super.setUp(); |
| |
| // Create an auth plugin |
| plugin = new JWTAuthPlugin(); |
| |
| testConfig = new HashMap<>(); |
| testConfig.put("class", "org.apache.solr.security.JWTAuthPlugin"); |
| testConfig.put("principalClaim", "customPrincipal"); |
| testConfig.put("jwk", testJwk); |
| plugin.init(testConfig); |
| |
| minimalConfig = new HashMap<>(); |
| minimalConfig.put("class", "org.apache.solr.security.JWTAuthPlugin"); |
| } |
| |
| @Override |
| @After |
| public void tearDown() throws Exception { |
| super.tearDown(); |
| if (null != plugin) { |
| plugin.close(); |
| plugin = null; |
| } |
| } |
| |
| @Test |
| public void initWithoutRequired() { |
| plugin.init(testConfig); |
| assertEquals(AUTZ_HEADER_PROBLEM, plugin.authenticate("foo").getAuthCode()); |
| } |
| |
| @Test |
| public void initFromSecurityJSONLocalJWK() throws Exception { |
| Path securityJson = TEST_PATH().resolve("security").resolve("jwt_plugin_jwk_security.json"); |
| InputStream is = Files.newInputStream(securityJson); |
| Map<String,Object> securityConf = (Map<String, Object>) Utils.fromJSON(is); |
| Map<String, Object> authConf = (Map<String, Object>) securityConf.get("authentication"); |
| plugin.init(authConf); |
| } |
| |
| @Test |
| public void initFromSecurityJSONUrlJwk() throws Exception { |
| Path securityJson = TEST_PATH().resolve("security").resolve("jwt_plugin_jwk_url_security.json"); |
| InputStream is = Files.newInputStream(securityJson); |
| Map<String,Object> securityConf = (Map<String, Object>) Utils.fromJSON(is); |
| Map<String, Object> authConf = (Map<String, Object>) securityConf.get("authentication"); |
| plugin.init(authConf); |
| |
| JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(testHeader); |
| assertEquals(JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.JWT_VALIDATION_EXCEPTION, resp.getAuthCode()); |
| assertTrue(resp.getJwtException().getMessage().contains("Connection refused")); |
| } |
| |
| @Test |
| public void initWithJwk() { |
| HashMap<String, Object> authConf = new HashMap<>(); |
| authConf.put("jwk", testJwk); |
| plugin = new JWTAuthPlugin(); |
| plugin.init(authConf); |
| } |
| |
| @Test |
| @Deprecated |
| public void initWithJwkUrlForBackwardsCompat() { |
| HashMap<String, Object> authConf = new HashMap<>(); |
| authConf.put("jwkUrl", "https://127.0.0.1:9999/foo.jwk"); |
| plugin = new JWTAuthPlugin(); |
| plugin.init(authConf); |
| assertEquals(1, plugin.getIssuerConfigs().size()); |
| assertEquals(1, plugin.getIssuerConfigs().get(0).getJwksUrls().size()); |
| } |
| |
| @Test |
| public void initWithJwksUrl() { |
| HashMap<String, Object> authConf = new HashMap<>(); |
| authConf.put("jwksUrl", "https://127.0.0.1:9999/foo.jwk"); |
| plugin = new JWTAuthPlugin(); |
| plugin.init(authConf); |
| assertEquals(1, plugin.getIssuerConfigs().size()); |
| assertEquals(1, plugin.getIssuerConfigs().get(0).getJwksUrls().size()); |
| } |
| |
| @Test |
| public void initWithJwkUrlArray() { |
| HashMap<String, Object> authConf = new HashMap<>(); |
| authConf.put("jwksUrl", Arrays.asList("https://127.0.0.1:9999/foo.jwk", "https://127.0.0.1:9999/foo2.jwk")); |
| authConf.put("iss", "myIssuer"); |
| plugin = new JWTAuthPlugin(); |
| plugin.init(authConf); |
| assertEquals(1, plugin.getIssuerConfigs().size()); |
| assertEquals(2, plugin.getIssuerConfigs().get(0).getJwksUrls().size()); |
| } |
| |
| @Test |
| public void authenticateOk() { |
| JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(testHeader); |
| assertTrue(resp.isAuthenticated()); |
| assertEquals("custom", resp.getPrincipal().getName()); // principalClaim = customPrincipal, not sub here |
| } |
| |
| @Test |
| public void authFailedMissingSubject() { |
| minimalConfig.put("principalClaim","sub"); // minimalConfig has no subject specified |
| plugin.init(minimalConfig); |
| JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(testHeader); |
| assertFalse(resp.isAuthenticated()); |
| assertEquals(JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.JWT_VALIDATION_EXCEPTION, resp.getAuthCode()); |
| |
| testConfig.put("principalClaim","sub"); // testConfig has subject = solruser |
| plugin.init(testConfig); |
| resp = plugin.authenticate(testHeader); |
| assertTrue(resp.isAuthenticated()); |
| } |
| |
| @Test |
| public void authFailedMissingIssuer() { |
| testConfig.put("iss", "NA"); |
| plugin.init(testConfig); |
| JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(testHeader); |
| assertFalse(resp.isAuthenticated()); |
| assertEquals(JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.JWT_VALIDATION_EXCEPTION, resp.getAuthCode()); |
| |
| testConfig.put("iss", "IDServer"); |
| plugin.init(testConfig); |
| resp = plugin.authenticate(testHeader); |
| assertTrue(resp.isAuthenticated()); |
| } |
| |
| @Test |
| public void authFailedMissingAudience() { |
| testConfig.put("aud", "NA"); |
| plugin.init(testConfig); |
| JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(testHeader); |
| assertFalse(resp.isAuthenticated()); |
| assertEquals(JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.JWT_VALIDATION_EXCEPTION, resp.getAuthCode()); |
| |
| testConfig.put("aud", "Solr"); |
| plugin.init(testConfig); |
| resp = plugin.authenticate(testHeader); |
| assertTrue(resp.isAuthenticated()); |
| } |
| |
| @Test |
| public void authFailedMissingPrincipal() { |
| testConfig.put("principalClaim", "customPrincipal"); |
| plugin.init(testConfig); |
| JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(testHeader); |
| assertTrue(resp.isAuthenticated()); |
| |
| testConfig.put("principalClaim", "NA"); |
| plugin.init(testConfig); |
| resp = plugin.authenticate(testHeader); |
| assertFalse(resp.isAuthenticated()); |
| assertEquals(JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.PRINCIPAL_MISSING, resp.getAuthCode()); |
| } |
| |
| @Test |
| public void claimMatch() { |
| // all custom claims match regex |
| Map<String, String> shouldMatch = new HashMap<>(); |
| shouldMatch.put("claim1", "foo"); |
| shouldMatch.put("claim2", "foo|bar"); |
| shouldMatch.put("claim3", "f\\w{2}$"); |
| testConfig.put("claimsMatch", shouldMatch); |
| plugin.init(testConfig); |
| JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(testHeader); |
| assertTrue(resp.isAuthenticated()); |
| |
| // Required claim does not exist |
| shouldMatch.clear(); |
| shouldMatch.put("claim9", "NA"); |
| plugin.init(testConfig); |
| resp = plugin.authenticate(testHeader); |
| assertEquals(JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.CLAIM_MISMATCH, resp.getAuthCode()); |
| |
| // Required claim does not match regex |
| shouldMatch.clear(); |
| shouldMatch.put("claim1", "NA"); |
| resp = plugin.authenticate(testHeader); |
| assertEquals(JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.CLAIM_MISMATCH, resp.getAuthCode()); |
| } |
| |
| @Test |
| public void missingIssAudExp() { |
| testConfig.put("requireIss", "false"); |
| testConfig.put("requireExp", "false"); |
| plugin.init(testConfig); |
| JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(slimHeader); |
| assertTrue(resp.getErrorMessage(), resp.isAuthenticated()); |
| |
| // Missing exp claim |
| testConfig.put("requireExp", true); |
| plugin.init(testConfig); |
| resp = plugin.authenticate(slimHeader); |
| assertEquals(JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.JWT_VALIDATION_EXCEPTION, resp.getAuthCode()); |
| testConfig.put("requireExp", false); |
| |
| // Missing issuer claim |
| testConfig.put("requireIss", true); |
| plugin.init(testConfig); |
| resp = plugin.authenticate(slimHeader); |
| assertEquals(JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.JWT_VALIDATION_EXCEPTION, resp.getAuthCode()); |
| } |
| |
| @Test |
| public void algWhitelist() { |
| testConfig.put("algWhitelist", Arrays.asList("PS384", "PS512")); |
| plugin.init(testConfig); |
| JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(testHeader); |
| assertEquals(JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.JWT_VALIDATION_EXCEPTION, resp.getAuthCode()); |
| assertTrue(resp.getErrorMessage().contains("not a whitelisted")); |
| } |
| |
| @Test |
| public void scope() { |
| testConfig.put("scope", "solr:read solr:admin"); |
| plugin.init(testConfig); |
| JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(testHeader); |
| assertTrue(resp.getErrorMessage(), resp.isAuthenticated()); |
| |
| // When 'rolesClaim' is not defined in config, then all scopes are registered as roles |
| Principal principal = resp.getPrincipal(); |
| assertTrue(principal instanceof VerifiedUserRoles); |
| Set<String> roles = ((VerifiedUserRoles)principal).getVerifiedRoles(); |
| assertEquals(1, roles.size()); |
| assertTrue(roles.contains("solr:read")); |
| } |
| |
| @Test |
| public void roles() { |
| testConfig.put("rolesClaim", "roles"); |
| plugin.init(testConfig); |
| JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(testHeader); |
| assertTrue(resp.getErrorMessage(), resp.isAuthenticated()); |
| |
| // When 'rolesClaim' is defined in config, then roles from that claim are used instead of claims |
| Principal principal = resp.getPrincipal(); |
| assertTrue(principal instanceof VerifiedUserRoles); |
| Set<String> roles = ((VerifiedUserRoles)principal).getVerifiedRoles(); |
| assertEquals(3, roles.size()); |
| assertTrue(roles.contains("group-one")); |
| assertTrue(roles.contains("other-group")); |
| assertTrue(roles.contains("group-three")); |
| } |
| |
| @Test |
| public void wrongScope() { |
| testConfig.put("scope", "wrong"); |
| plugin.init(testConfig); |
| JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(testHeader); |
| assertFalse(resp.isAuthenticated()); |
| assertNull(resp.getPrincipal()); |
| assertEquals(SCOPE_MISSING, resp.getAuthCode()); |
| } |
| |
| @Test |
| public void noHeaderBlockUnknown() { |
| testConfig.put("blockUnknown", true); |
| plugin.init(testConfig); |
| JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(null); |
| assertEquals(NO_AUTZ_HEADER, resp.getAuthCode()); |
| } |
| |
| @Test |
| public void noHeaderNotBlockUnknown() { |
| testConfig.put("blockUnknown", false); |
| plugin.init(testConfig); |
| JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(null); |
| assertEquals(JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.PASS_THROUGH, resp.getAuthCode()); |
| } |
| |
| @Test |
| public void minimalConfigPassThrough() { |
| minimalConfig.put("blockUnknown", false); |
| plugin.init(minimalConfig); |
| JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(null); |
| assertEquals(JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.PASS_THROUGH, resp.getAuthCode()); |
| } |
| |
| @Test |
| public void wellKnownConfigNoHeaderPassThrough() { |
| String wellKnownUrl = TEST_PATH().resolve("security").resolve("jwt_well-known-config.json").toAbsolutePath().toUri().toString(); |
| testConfig.put("wellKnownUrl", wellKnownUrl); |
| testConfig.remove("jwk"); |
| plugin.init(testConfig); |
| JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(null); |
| assertEquals(JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.PASS_THROUGH, resp.getAuthCode()); |
| } |
| |
| @Test |
| public void defaultRealm() { |
| String wellKnownUrl = TEST_PATH().resolve("security").resolve("jwt_well-known-config.json").toAbsolutePath().toUri().toString(); |
| testConfig.put("wellKnownUrl", wellKnownUrl); |
| testConfig.remove("jwk"); |
| plugin.init(testConfig); |
| assertEquals("solr-jwt", plugin.realm); |
| } |
| |
| @Test |
| public void configureRealm() { |
| String wellKnownUrl = TEST_PATH().resolve("security").resolve("jwt_well-known-config.json").toAbsolutePath().toUri().toString(); |
| testConfig.put("wellKnownUrl", wellKnownUrl); |
| testConfig.remove("jwk"); |
| testConfig.put("realm", "myRealm"); |
| plugin.init(testConfig); |
| assertEquals("myRealm", plugin.realm); |
| } |
| |
| @Test(expected = SolrException.class) |
| public void bothJwksUrlAndJwkFails() { |
| testConfig.put("jwksUrl", "http://127.0.0.1:45678/myJwk"); |
| plugin.init(testConfig); |
| } |
| |
| @Test |
| public void xSolrAuthDataHeader() { |
| testConfig.put("adminUiScope", "solr:admin"); |
| testConfig.put("authorizationEndpoint", "http://acmepaymentscorp/oauth/auz/authorize"); |
| testConfig.put("clientId", "solr-cluster"); |
| plugin.init(testConfig); |
| String headerBase64 = plugin.generateAuthDataHeader(); |
| String headerJson = new String(Base64.base64ToByteArray(headerBase64), StandardCharsets.UTF_8); |
| Map<String,String> parsed = (Map<String, String>) Utils.fromJSONString(headerJson); |
| assertEquals("solr:admin", parsed.get("scope")); |
| assertEquals("http://acmepaymentscorp/oauth/auz/authorize", parsed.get("authorizationEndpoint")); |
| assertEquals("solr-cluster", parsed.get("client_id")); |
| } |
| |
| @Test |
| public void initWithTwoIssuers() { |
| HashMap<String, Object> authConf = new HashMap<>(); |
| JWTIssuerConfig iss1 = new JWTIssuerConfig("iss1").setIss("1").setAud("aud1") |
| .setJwksUrl("https://127.0.0.1:9999/foo.jwk"); |
| JWTIssuerConfig iss2 = new JWTIssuerConfig("iss2").setIss("2").setAud("aud2") |
| .setJwksUrl(Arrays.asList("https://127.0.0.1:9999/foo.jwk", "https://127.0.0.1:9999/foo2.jwk")); |
| authConf.put("issuers", Arrays.asList(iss1.asConfig(), iss2.asConfig())); |
| plugin = new JWTAuthPlugin(); |
| plugin.init(authConf); |
| assertEquals(2, plugin.getIssuerConfigs().size()); |
| assertTrue(plugin.getIssuerConfigs().get(0).usesHttpsJwk()); |
| assertTrue(plugin.getIssuerConfigs().get(1).usesHttpsJwk()); |
| JWTIssuerConfig issuer1 = plugin.getIssuerConfigByName("iss1"); |
| JWTIssuerConfig issuer2 = plugin.getIssuerConfigByName("iss2"); |
| assertNotNull(issuer1); |
| assertNotNull(issuer2); |
| assertEquals(2, issuer2.getJwksUrls().size()); |
| assertEquals("iss1", plugin.getPrimaryIssuer().getName()); |
| assertEquals("aud1", issuer1.getAud()); |
| } |
| |
| @Test |
| public void initWithToplevelAndIssuersCombined() { |
| HashMap<String, Object> authConf = new HashMap<>(); |
| JWTIssuerConfig iss1 = new JWTIssuerConfig("iss1").setIss("1").setAud("aud1") |
| .setJwksUrl("https://127.0.0.1:9999/foo.jwk"); |
| authConf.put("issuers", Collections.singletonList(iss1.asConfig())); |
| authConf.put("aud", "aud2"); |
| authConf.put("jwksUrl", Arrays.asList("https://127.0.0.1:9999/foo.jwk", "https://127.0.0.1:9999/foo2.jwk")); |
| |
| plugin = new JWTAuthPlugin(); |
| plugin.init(authConf); |
| assertEquals(2, plugin.getIssuerConfigs().size()); |
| assertEquals("PRIMARY", plugin.getPrimaryIssuer().getName()); |
| assertEquals("aud2", plugin.getPrimaryIssuer().getAud()); |
| // Top-level (name=PRIMARY) issuer config does not need "iss" for back compat |
| assertNull(plugin.getPrimaryIssuer().getIss()); |
| } |
| } |