Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading