diff --git a/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTAuthPluginTest.java b/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTAuthPluginTest.java index 3d4dce30729..7ee2cc01550 100644 --- a/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTAuthPluginTest.java +++ b/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTAuthPluginTest.java @@ -18,6 +18,7 @@ import static org.apache.solr.security.jwt.JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.AUTZ_HEADER_PROBLEM; import static org.apache.solr.security.jwt.JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.CLAIM_MISMATCH; +import static org.apache.solr.security.jwt.JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.JWT_EXPIRED; import static org.apache.solr.security.jwt.JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.JWT_VALIDATION_EXCEPTION; import static org.apache.solr.security.jwt.JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.NO_AUTZ_HEADER; import static org.apache.solr.security.jwt.JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.PASS_THROUGH; @@ -52,6 +53,7 @@ import org.jose4j.jws.AlgorithmIdentifiers; import org.jose4j.jws.JsonWebSignature; import org.jose4j.jwt.JwtClaims; +import org.jose4j.jwt.NumericDate; import org.jose4j.keys.BigEndianBigInteger; import org.jose4j.lang.JoseException; import org.junit.After; @@ -746,4 +748,92 @@ public void testRegisterTokenEndpointForCsp() { "http://acmepaymentscorp/oauth/oauth20/token", EnvUtils.getProperty(LoadAdminUiServlet.SYSPROP_CSP_CONNECT_SRC_URLS)); } + + @Test + public void requireIssuerFalseButIssPresentAndMismatches() { + // requireIssuer=false controls whether iss must be present, not whether a mismatching value + // is silently accepted. A token with iss="IDServer" should fail when iss="NA" is configured. + testConfig.put("iss", "NA"); + testConfig.put("requireIss", false); + plugin.init(testConfig); + JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(testHeader); + assertFalse(resp.isAuthenticated()); + assertEquals(JWT_VALIDATION_EXCEPTION, resp.getAuthCode()); + } + + @Test + public void requireIssuerFalseNoIssInTokenOrConfig() { + // requireIssuer=false with no iss claim in token and no iss in config → authenticated + testConfig.put("requireIss", false); + testConfig.put("requireExp", false); + plugin.init(testConfig); + JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(slimHeader); + assertTrue(resp.getErrorMessage(), resp.isAuthenticated()); + } + + @Test + public void scopeClaimAsJsonArray() throws Exception { + // Verify that a scope claim expressed as a JSON array (not just a whitespace-separated String) + // is correctly parsed: authentication succeeds and "openid" is filtered out of the roles. + JwtClaims claims = generateClaims(); + claims.setClaim("scope", Arrays.asList("solr:read", "openid")); + JsonWebSignature jws = new JsonWebSignature(); + jws.setPayload(claims.toJson()); + jws.setKey(rsaJsonWebKey.getPrivateKey()); + jws.setKeyIdHeaderValue(rsaJsonWebKey.getKeyId()); + jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256); + String header = "Bearer " + jws.getCompactSerialization(); + + testConfig.put("scope", "solr:read"); + plugin.init(testConfig); + JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(header); + assertTrue(resp.getErrorMessage(), resp.isAuthenticated()); + Set roles = ((VerifiedUserRoles) resp.getPrincipal()).getVerifiedRoles(); + assertTrue(roles.contains("solr:read")); + assertFalse("openid should be filtered from roles", roles.contains("openid")); + } + + @Test + public void tokenExpiredWithinClockSkewIsAuthenticated() throws Exception { + // Token expired 25 seconds ago — within the 30-second clock skew tolerance. + // All timestamps must be consistent: iat < exp, so iat is set 90 seconds in the past. + NumericDate now = NumericDate.now(); + JwtClaims claims = new JwtClaims(); + claims.setIssuer("IDServer"); + claims.setClaim("customPrincipal", "custom"); + claims.setIssuedAt(NumericDate.fromSeconds(now.getValue() - 90)); + claims.setNotBefore(NumericDate.fromSeconds(now.getValue() - 90)); + claims.setExpirationTime(NumericDate.fromSeconds(now.getValue() - 25)); + JsonWebSignature jws = new JsonWebSignature(); + jws.setPayload(claims.toJson()); + jws.setKey(rsaJsonWebKey.getPrivateKey()); + jws.setKeyIdHeaderValue(rsaJsonWebKey.getKeyId()); + jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256); + String header = "Bearer " + jws.getCompactSerialization(); + + JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(header); + assertTrue(resp.getErrorMessage(), resp.isAuthenticated()); + } + + @Test + public void tokenExpiredBeyondClockSkewIsRejected() throws Exception { + // Token expired 35 seconds ago — beyond the 30-second clock skew tolerance. + NumericDate now = NumericDate.now(); + JwtClaims claims = new JwtClaims(); + claims.setIssuer("IDServer"); + claims.setClaim("customPrincipal", "custom"); + claims.setIssuedAt(NumericDate.fromSeconds(now.getValue() - 90)); + claims.setNotBefore(NumericDate.fromSeconds(now.getValue() - 90)); + claims.setExpirationTime(NumericDate.fromSeconds(now.getValue() - 35)); + JsonWebSignature jws = new JsonWebSignature(); + jws.setPayload(claims.toJson()); + jws.setKey(rsaJsonWebKey.getPrivateKey()); + jws.setKeyIdHeaderValue(rsaJsonWebKey.getKeyId()); + jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256); + String header = "Bearer " + jws.getCompactSerialization(); + + JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(header); + assertFalse(resp.isAuthenticated()); + assertEquals(JWT_EXPIRED, resp.getAuthCode()); + } } diff --git a/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTIssuerConfigTest.java b/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTIssuerConfigTest.java index b208e73a1f3..13f50711429 100644 --- a/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTIssuerConfigTest.java +++ b/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTIssuerConfigTest.java @@ -242,4 +242,12 @@ public void wellKnownConfigNotReachable() { "Well-known config could not be read from url https://127.0.0.1:45678/.well-known/config", e.getMessage()); } + + @Test + public void parseJwkSetSingleBareJwk() throws Exception { + // testJwk is a bare JWK map (no "keys" wrapper) — exercises the single-JWK branch + JsonWebKeySet result = JWTIssuerConfig.parseJwkSet(testJwk); + assertEquals(1, result.getJsonWebKeys().size()); + assertEquals("k1", result.getJsonWebKeys().get(0).getKeyId()); + } } diff --git a/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTVerificationkeyResolverTest.java b/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTVerificationkeyResolverTest.java index 3406e439dbb..cc3d3ad253e 100644 --- a/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTVerificationkeyResolverTest.java +++ b/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTVerificationkeyResolverTest.java @@ -21,17 +21,25 @@ import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.when; +import java.security.Key; +import java.security.interfaces.ECPublicKey; import java.util.Arrays; import java.util.Iterator; import java.util.List; import org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.common.SolrException; import org.apache.solr.security.jwt.JWTIssuerConfig.HttpsJwksFactory; +import org.jose4j.jwk.EcJwkGenerator; +import org.jose4j.jwk.EllipticCurveJsonWebKey; import org.jose4j.jwk.HttpsJwks; import org.jose4j.jwk.JsonWebKey; +import org.jose4j.jwk.JsonWebKeySet; 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.EllipticCurves; import org.jose4j.lang.JoseException; import org.jose4j.lang.UnresolvableKeyException; import org.junit.Before; @@ -118,6 +126,93 @@ public void notFoundKey() throws JoseException { resolver.resolveKey(k5.getJws(), null); } + @Test + public void noIssRequireIssuerFalseSingleIssuerFallback() throws Exception { + // null iss, requireIssuer=false, single issuer → falls back to that issuer + when(httpsJwksFactory.createList(ArgumentMatchers.anyList())).thenReturn(asList(firstJwkList)); + JWTIssuerConfig singleIssuerConfig = new JWTIssuerConfig("single").setJwksUrl(asList("url1")); + resolver = new JWTVerificationkeyResolver(Arrays.asList(singleIssuerConfig), false); + + Key key = resolver.resolveKey(makeJws(k1, claimsWithNoIss()), null); + assertNotNull(key); + } + + @Test(expected = SolrException.class) + public void noIssRequireIssuerFalseMultipleIssuersThrows() throws Exception { + // null iss, requireIssuer=false, multiple issuers → SolrException (ambiguous) + JWTIssuerConfig iss1 = new JWTIssuerConfig("iss1").setIss("A").setJwksUrl(asList("url1")); + JWTIssuerConfig iss2 = new JWTIssuerConfig("iss2").setIss("B").setJwksUrl(asList("url2")); + resolver = new JWTVerificationkeyResolver(Arrays.asList(iss1, iss2), false); + resolver.resolveKey(makeJws(k1, claimsWithNoIss()), null); + } + + @Test + public void issMismatchSingleIssuerBackCompatFallback() throws Exception { + // iss present but unrecognised, single issuer → back-compat fallback to that issuer + when(httpsJwksFactory.createList(ArgumentMatchers.anyList())).thenReturn(asList(firstJwkList)); + JWTIssuerConfig singleIssuerConfig = + new JWTIssuerConfig("single").setIss("A").setJwksUrl(asList("url1")); + resolver = new JWTVerificationkeyResolver(Arrays.asList(singleIssuerConfig), true); + + Key key = resolver.resolveKey(makeJws(k1, claimsWithIss("UNKNOWN")), null); + assertNotNull(key); + } + + @Test(expected = UnresolvableKeyException.class) + public void issMismatchMultipleIssuersThrows() throws Exception { + // iss present but unrecognised, multiple issuers → UnresolvableKeyException + JWTIssuerConfig iss1 = new JWTIssuerConfig("iss1").setIss("A").setJwksUrl(asList("url1")); + JWTIssuerConfig iss2 = new JWTIssuerConfig("iss2").setIss("B").setJwksUrl(asList("url2")); + resolver = new JWTVerificationkeyResolver(Arrays.asList(iss1, iss2), true); + resolver.resolveKey(makeJws(k1, claimsWithIss("UNKNOWN")), null); + } + + @Test + public void ecKeyTypeMaterialisedCorrectly() throws Exception { + // EC key type should be returned as ECPublicKey, not RSAPublicKey + EllipticCurveJsonWebKey ecKey = EcJwkGenerator.generateJwk(EllipticCurves.P256); + ecKey.setKeyId("ec1"); + JsonWebKey ecPublicKey = JsonWebKey.Factory.newJwk(ecKey.getECPublicKey()); + ecPublicKey.setKeyId("ec1"); + JWTIssuerConfig ecIssuerConfig = + new JWTIssuerConfig("ec-issuer") + .setIss("ec-iss") + .setJsonWebKeySet(new JsonWebKeySet(ecPublicKey)); + resolver = new JWTVerificationkeyResolver(Arrays.asList(ecIssuerConfig), false); + + JsonWebSignature ecJws = new JsonWebSignature(); + ecJws.setPayload(claimsWithIss("ec-iss").toJson()); + ecJws.setKey(ecKey.getPrivateKey()); + ecJws.setKeyIdHeaderValue("ec1"); + ecJws.setAlgorithmHeaderValue(AlgorithmIdentifiers.ECDSA_USING_P256_CURVE_AND_SHA256); + + Key key = resolver.resolveKey(ecJws, null); + assertNotNull(key); + assertTrue(key instanceof ECPublicKey); + } + + private static JwtClaims claimsWithNoIss() { + JwtClaims claims = new JwtClaims(); + claims.setExpirationTimeMinutesInTheFuture(10); + return claims; + } + + private static JwtClaims claimsWithIss(String iss) { + JwtClaims claims = claimsWithNoIss(); + claims.setIssuer(iss); + return claims; + } + + private static JsonWebSignature makeJws(KeyHolder keyHolder, JwtClaims claims) + throws JoseException { + JsonWebSignature jws = new JsonWebSignature(); + jws.setPayload(claims.toJson()); + jws.setKey(keyHolder.getRsaKey().getPrivateKey()); + jws.setKeyIdHeaderValue(keyHolder.getRsaKey().getKeyId()); + jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256); + return jws; + } + @SuppressWarnings("NewClassNamingConvention") public static class KeyHolder { private final RsaJsonWebKey key;