From edc216e6c73f6eca7e9a482363fe05e73e5a2ae2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20H=C3=B8ydahl?= Date: Sun, 26 Apr 2026 02:46:21 +0200 Subject: [PATCH 1/3] Add pre-migration unit tests for jwt-auth module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Increases test coverage for key resolution, issuer fallback, clock skew tolerance, scope handling, and JWK parsing — all using the existing jose4j API so tests pass on main before the nimbus migration is merged. New tests in JWTVerificationkeyResolverTest: - noIssRequireIssuerFalseSingleIssuerFallback: null iss + single issuer falls back - noIssRequireIssuerFalseMultipleIssuersThrows: null iss + multiple issuers → SolrException - issMismatchSingleIssuerBackCompatFallback: unrecognised iss + single issuer falls back - issMismatchMultipleIssuersThrows: unrecognised iss + multiple issuers → UnresolvableKeyException - ecKeyTypeMaterialisedCorrectly: EC key resolves as ECPublicKey New tests in JWTAuthPluginTest: - requireIssuerFalseButIssPresentAndMismatches: mismatched iss → JWT_VALIDATION_EXCEPTION - requireIssuerFalseNoIssInTokenOrConfig: absent iss + requireIss=false → authenticated - scopeClaimAsJsonArray: scope as JSON array is parsed and filtered correctly - tokenExpiredWithinClockSkewIsAuthenticated: exp=now-25s → authenticated (30s skew) - tokenExpiredBeyondClockSkewIsRejected: exp=now-35s → JWT_EXPIRED New test in JWTIssuerConfigTest: - parseJwkSetSingleBareJwk: bare JWK map (no "keys" wrapper) → JWKSet with 1 key --- .../solr/security/jwt/JWTAuthPluginTest.java | 90 ++++++++++++++++++ .../security/jwt/JWTIssuerConfigTest.java | 9 ++ .../jwt/JWTVerificationkeyResolverTest.java | 95 +++++++++++++++++++ 3 files changed, 194 insertions(+) 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 3d4dce307292..210f155b4c9c 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. + long nowMs = System.currentTimeMillis(); + JwtClaims claims = new JwtClaims(); + claims.setIssuer("IDServer"); + claims.setClaim("customPrincipal", "custom"); + claims.setIssuedAt(NumericDate.fromMilliseconds(nowMs - 90 * 1000)); + claims.setNotBefore(NumericDate.fromMilliseconds(nowMs - 90 * 1000)); + claims.setExpirationTime(NumericDate.fromMilliseconds(nowMs - 25 * 1000)); + 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. + long nowMs = System.currentTimeMillis(); + JwtClaims claims = new JwtClaims(); + claims.setIssuer("IDServer"); + claims.setClaim("customPrincipal", "custom"); + claims.setIssuedAt(NumericDate.fromMilliseconds(nowMs - 90 * 1000)); + claims.setNotBefore(NumericDate.fromMilliseconds(nowMs - 90 * 1000)); + claims.setExpirationTime(NumericDate.fromMilliseconds(nowMs - 35 * 1000)); + 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 b208e73a1f3e..7e5e2507fa42 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 @@ -31,6 +31,7 @@ import java.util.Map; import org.apache.solr.SolrTestCase; import org.apache.solr.common.SolrException; +import org.jose4j.jwk.JsonWebKey; import org.jose4j.jwk.JsonWebKeySet; import org.junit.After; import org.junit.Before; @@ -242,4 +243,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 3406e439dbb1..cc3d3ad253ed 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; From 06ef39d1f0c70f9fb7f675130220eef4cd7cd558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20H=C3=B8ydahl?= Date: Sun, 26 Apr 2026 02:48:52 +0200 Subject: [PATCH 2/3] Tidy --- .../test/org/apache/solr/security/jwt/JWTIssuerConfigTest.java | 1 - 1 file changed, 1 deletion(-) 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 7e5e2507fa42..13f507114299 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 @@ -31,7 +31,6 @@ import java.util.Map; import org.apache.solr.SolrTestCase; import org.apache.solr.common.SolrException; -import org.jose4j.jwk.JsonWebKey; import org.jose4j.jwk.JsonWebKeySet; import org.junit.After; import org.junit.Before; From 85256ea758a1abbdb12da22f771bce7887eb9dad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20H=C3=B8ydahl?= Date: Sun, 26 Apr 2026 12:53:31 +0200 Subject: [PATCH 3/3] Replace System.currentTimeMillis() with NumericDate.now() in clock skew tests Avoids forbidden API (System#currentTimeMillis) flagged by forbiddenApisTest. --- .../solr/security/jwt/JWTAuthPluginTest.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 210f155b4c9c..7ee2cc015509 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 @@ -797,13 +797,13 @@ public void scopeClaimAsJsonArray() throws Exception { 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. - long nowMs = System.currentTimeMillis(); + NumericDate now = NumericDate.now(); JwtClaims claims = new JwtClaims(); claims.setIssuer("IDServer"); claims.setClaim("customPrincipal", "custom"); - claims.setIssuedAt(NumericDate.fromMilliseconds(nowMs - 90 * 1000)); - claims.setNotBefore(NumericDate.fromMilliseconds(nowMs - 90 * 1000)); - claims.setExpirationTime(NumericDate.fromMilliseconds(nowMs - 25 * 1000)); + 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()); @@ -818,13 +818,13 @@ public void tokenExpiredWithinClockSkewIsAuthenticated() throws Exception { @Test public void tokenExpiredBeyondClockSkewIsRejected() throws Exception { // Token expired 35 seconds ago — beyond the 30-second clock skew tolerance. - long nowMs = System.currentTimeMillis(); + NumericDate now = NumericDate.now(); JwtClaims claims = new JwtClaims(); claims.setIssuer("IDServer"); claims.setClaim("customPrincipal", "custom"); - claims.setIssuedAt(NumericDate.fromMilliseconds(nowMs - 90 * 1000)); - claims.setNotBefore(NumericDate.fromMilliseconds(nowMs - 90 * 1000)); - claims.setExpirationTime(NumericDate.fromMilliseconds(nowMs - 35 * 1000)); + 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());