diff --git a/changelog/unreleased/SOLR-18211-migrate-jwt-nimbus.yml b/changelog/unreleased/SOLR-18211-migrate-jwt-nimbus.yml new file mode 100644 index 000000000000..d2b8c048fd6d --- /dev/null +++ b/changelog/unreleased/SOLR-18211-migrate-jwt-nimbus.yml @@ -0,0 +1,8 @@ +title: "Replace unmaintained bc-jose4j JWT library with nimbus-jose-jwt in the jwt-auth module." +type: dependency_update +authors: + - name: Jan Høydahl + url: https://home.apache.org/phonebook.html?uid=janhoy +links: + - name: SOLR-18211 + url: https://issues.apache.org/jira/browse/SOLR-18211 diff --git a/solr/modules/jwt-auth/build.gradle b/solr/modules/jwt-auth/build.gradle index 89f4f1a4ac71..a3f9b3644305 100644 --- a/solr/modules/jwt-auth/build.gradle +++ b/solr/modules/jwt-auth/build.gradle @@ -35,7 +35,7 @@ dependencies { implementation project(':solr:solrj') implementation project(':solr:solrj-jetty') - implementation libs.bc.jose4j + implementation libs.nimbusds.josejwt implementation libs.eclipse.jetty.client implementation libs.eclipse.jetty.http @@ -62,7 +62,6 @@ dependencies { testImplementation libs.bouncycastle.bcpkix testImplementation libs.bouncycastle.bcprov - testImplementation libs.nimbusds.josejwt testImplementation libs.squareup.okhttp3.mockwebserver testImplementation libs.squareup.okhttp3.okhttp testRuntimeOnly libs.netty.codechttp diff --git a/solr/modules/jwt-auth/gradle.lockfile b/solr/modules/jwt-auth/gradle.lockfile index de3055b0b4b2..64570cb8d407 100644 --- a/solr/modules/jwt-auth/gradle.lockfile +++ b/solr/modules/jwt-auth/gradle.lockfile @@ -34,7 +34,7 @@ com.jayway.jsonpath:json-path:2.9.0=jarValidation,runtimeClasspath,runtimeLibs,s com.lmax:disruptor:4.0.0=solrPlatformLibs com.nimbusds:content-type:2.2=jarValidation,testCompileClasspath,testRuntimeClasspath com.nimbusds:lang-tag:1.7=jarValidation,testCompileClasspath,testRuntimeClasspath -com.nimbusds:nimbus-jose-jwt:10.5=jarValidation,testCompileClasspath,testRuntimeClasspath +com.nimbusds:nimbus-jose-jwt:10.5=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath com.nimbusds:oauth2-oidc-sdk:10.10.1=jarValidation,testCompileClasspath,testRuntimeClasspath com.squareup.okhttp3:mockwebserver:4.12.0=jarValidation,testCompileClasspath,testRuntimeClasspath com.squareup.okhttp3:okhttp:4.12.0=jarValidation,testCompileClasspath,testRuntimeClasspath @@ -137,7 +137,6 @@ org.apache.lucene:lucene-test-framework:10.4.0=jarValidation,testCompileClasspat org.apache.zookeeper:zookeeper-jute:3.9.4=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath org.apache.zookeeper:zookeeper:3.9.4=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath org.apiguardian:apiguardian-api:1.1.2=jarValidation,testRuntimeClasspath -org.bitbucket.b_c:jose4j:0.9.6=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath org.bouncycastle:bcpkix-jdk15on:1.70=jarValidation,testRuntimeClasspath org.bouncycastle:bcpkix-jdk18on:1.84=jarValidation,testCompileClasspath,testRuntimeClasspath org.bouncycastle:bcprov-jdk15on:1.70=jarValidation,testRuntimeClasspath diff --git a/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/IssuerAwareJWSKeySelector.java b/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/IssuerAwareJWSKeySelector.java new file mode 100644 index 000000000000..a1dc6dae8af0 --- /dev/null +++ b/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/IssuerAwareJWSKeySelector.java @@ -0,0 +1,218 @@ +/* + * 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.jwt; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.KeySourceException; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKMatcher; +import com.nimbusds.jose.jwk.JWKSelector; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.OctetSequenceKey; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.proc.JWSKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import java.io.IOException; +import java.lang.invoke.MethodHandles; +import java.security.Key; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import javax.net.ssl.SSLHandshakeException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Resolves JWS signature verification keys from a set of {@link JWTIssuerConfig} objects, which may + * represent any valid configuration in Solr's security.json, i.e. static list of JWKs or keys + * retrieved from HTTPS JWK endpoints. + * + *

This implementation maintains a map of issuers, each with its own list of {@link JWK}, and + * resolves the correct key from the correct issuer. The issuer is passed in via {@link + * IssuerContext}. + * + *

If a key is not found and the issuer is backed by HTTPS JWKs, one cache refresh is attempted + * before failing. + */ +public class IssuerAwareJWSKeySelector implements JWSKeySelector { + private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + private final Map issuerConfigs = new HashMap<>(); + private final boolean requireIssuer; + + /** + * SecurityContext subclass that carries the (unverified) issuer claim from the JWT payload, + * allowing the key selector to look up the correct issuer configuration. + */ + public static class IssuerContext implements SecurityContext { + private final String issuer; + + public IssuerContext(String issuer) { + this.issuer = issuer; + } + + public String getIssuer() { + return issuer; + } + } + + /** + * Resolves key from a JWKs from one or more IssuerConfigs + * + * @param issuerConfigs Collection of configuration objects for the issuer(s) + * @param requireIssuer if true, will require 'iss' claim on jws + */ + public IssuerAwareJWSKeySelector( + Collection issuerConfigs, boolean requireIssuer) { + this.requireIssuer = requireIssuer; + issuerConfigs.forEach(ic -> this.issuerConfigs.put(ic.getIss(), ic)); + } + + @Override + public List selectJWSKeys(JWSHeader header, SecurityContext context) + throws KeySourceException { + String tokenIssuer = + (context instanceof IssuerContext) ? ((IssuerContext) context).getIssuer() : null; + + JWTIssuerConfig issuerConfig = resolveIssuerConfig(tokenIssuer); + + List allJwks = new ArrayList<>(); + String keysSource = "N/A"; + try { + if (issuerConfig.usesHttpsJwk()) { + keysSource = "[" + String.join(", ", issuerConfig.getJwksUrls()) + "]"; + for (JWTIssuerConfig.JwkSetFetcher fetcher : issuerConfig.getHttpsJwks()) { + try { + allJwks.addAll(fetcher.getKeys()); + } catch (SSLHandshakeException e) { + throw new KeySourceException( + "Failed to connect with " + + fetcher.getLocation() + + ", do you have the correct SSL certificate configured?", + e); + } + } + } else { + keysSource = "static list of keys in security.json"; + allJwks.addAll(issuerConfig.getJsonWebKeySet().getKeys()); + } + } catch (IOException | ParseException e) { + throw new KeySourceException( + String.format( + Locale.ROOT, "Unable to fetch JWKs from source %s: %s", keysSource, e.getMessage()), + e); + } + + JWKSelector selector = new JWKSelector(JWKMatcher.forJWSHeader(header)); + List matchingJwks = selector.select(new JWKSet(allJwks)); + + if (matchingJwks.isEmpty() && issuerConfig.usesHttpsJwk()) { + if (log.isDebugEnabled()) { + log.debug( + "No matching key found for JWS header {} in {} keys from {}; refreshing", + header, + allJwks.size(), + keysSource); + } + allJwks.clear(); + try { + for (JWTIssuerConfig.JwkSetFetcher fetcher : issuerConfig.getHttpsJwks()) { + fetcher.refresh(); + allJwks.addAll(fetcher.getKeys()); + } + } catch (IOException | ParseException e) { + throw new KeySourceException("Failed to refresh JWKs from " + keysSource + ": " + e, e); + } + matchingJwks = selector.select(new JWKSet(allJwks)); + } + + if (matchingJwks.isEmpty()) { + throw new KeySourceException( + String.format( + Locale.ROOT, + "Unable to find a suitable verification key for JWS w/ header %s from %d keys from source %s", + header, + allJwks.size(), + keysSource)); + } + + List keys = new ArrayList<>(); + for (JWK jwk : matchingJwks) { + try { + if (jwk instanceof RSAKey) { + keys.add(((RSAKey) jwk).toPublicKey()); + } else if (jwk instanceof ECKey) { + keys.add(((ECKey) jwk).toPublicKey()); + } else if (jwk instanceof OctetSequenceKey) { + keys.add(((OctetSequenceKey) jwk).toSecretKey()); + } else { + log.warn("Unsupported JWK type: {}", jwk.getKeyType()); + } + } catch (JOSEException e) { + log.warn("Failed to convert JWK to Key", e); + } + } + + if (keys.isEmpty()) { + throw new KeySourceException( + "Could not extract a usable public key from matched JWK(s) for header " + header); + } + + return keys; + } + + private JWTIssuerConfig resolveIssuerConfig(String tokenIssuer) throws KeySourceException { + if (tokenIssuer == null) { + if (requireIssuer) { + throw new KeySourceException("Token does not contain required issuer claim"); + } else if (issuerConfigs.size() == 1) { + return issuerConfigs.values().iterator().next(); + } else { + throw new KeySourceException( + "Signature verification not supported for multiple issuers without 'iss' claim in token."); + } + } else { + JWTIssuerConfig config = issuerConfigs.get(tokenIssuer); + if (config == null) { + if (issuerConfigs.size() > 1) { + throw new KeySourceException( + "No issuers configured for iss='" + tokenIssuer + "', cannot validate signature"); + } else if (issuerConfigs.size() == 1) { + config = issuerConfigs.values().iterator().next(); + log.debug( + "No issuer matching token's iss claim, but exactly one configured, selecting that one"); + } else { + throw new KeySourceException( + "Signature verification failed due to no configured issuer with id " + tokenIssuer); + } + } + return config; + } + } + + Set getIssuerConfigs() { + return new HashSet<>(issuerConfigs.values()); + } +} diff --git a/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTAuthPlugin.java b/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTAuthPlugin.java index 3e60cd71898a..749a4b005a03 100644 --- a/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTAuthPlugin.java +++ b/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTAuthPlugin.java @@ -16,6 +16,15 @@ */ package org.apache.solr.security.jwt; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.proc.BadJOSEException; +import com.nimbusds.jose.proc.BadJWSException; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.nimbusds.jwt.proc.BadJWTException; +import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; import jakarta.servlet.FilterChain; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -27,6 +36,7 @@ import java.nio.file.Path; import java.security.Principal; import java.security.cert.X509Certificate; +import java.text.ParseException; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; @@ -61,15 +71,6 @@ import org.apache.solr.util.CryptoKeys; import org.eclipse.jetty.client.Request; import org.eclipse.jetty.http.HttpHeader; -import org.jose4j.jwa.AlgorithmConstraints; -import org.jose4j.jwk.HttpsJwks; -import org.jose4j.jwt.JwtClaims; -import org.jose4j.jwt.MalformedClaimException; -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.lang.JoseException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -127,7 +128,7 @@ public class JWTAuthPlugin extends AuthenticationPlugin JWTIssuerConfig.PARAM_TOKEN_ENDPOINT, JWTIssuerConfig.PARAM_AUTHORIZATION_FLOW); - private JwtConsumer jwtConsumer; + private DefaultJWTProcessor jwtProcessor; private boolean requireExpirationTime; private List algAllowlist; private String principalClaim; @@ -141,7 +142,7 @@ public class JWTAuthPlugin extends AuthenticationPlugin private List redirectUris; private List issuerConfigs; private boolean requireIssuer; - private JWTVerificationkeyResolver verificationKeyResolver; + private IssuerAwareJWSKeySelector verificationKeyResolver; private Collection trustedSslCerts; String realm; private final CoreContainer coreContainer; @@ -239,7 +240,7 @@ public void init(Map pluginConfig) { // Add issuers from 'issuers' key issuerConfigs.addAll(parseIssuers(pluginConfig)); - verificationKeyResolver = new JWTVerificationkeyResolver(issuerConfigs, requireIssuer); + verificationKeyResolver = new IssuerAwareJWSKeySelector(issuerConfigs, requireIssuer); if (!issuerConfigs.isEmpty() && getPrimaryIssuer().getAuthorizationEndpoint() != null) { adminUiScope = (String) pluginConfig.get(PARAM_ADMINUI_SCOPE); @@ -354,9 +355,9 @@ private Optional parseIssuerFromTopLevelConfig(Map issuerConfig = issuerConfigs.stream().filter(ic -> issuer.equals(ic.getIss())).findFirst(); @@ -452,8 +453,12 @@ public boolean doAuthenticate( "Signature validation failed for issuer {}. Refreshing JWKs from IdP before trying again: {}", issuer, exceptionMessage); - for (HttpsJwks httpsJwks : issuerConfig.get().getHttpsJwks()) { - httpsJwks.refresh(); + for (JWTIssuerConfig.JwkSetFetcher fetcher : issuerConfig.get().getHttpsJwks()) { + try { + fetcher.refresh(); + } catch (IOException | ParseException e) { + log.warn("Failed to refresh JWKs from {}", fetcher.getLocation(), e); + } } authResponse = authenticate(header); // Retry exceptionMessage = @@ -462,7 +467,7 @@ public boolean doAuthenticate( : ""; } } - } catch (InvalidJwtException ex) { + } catch (ParseException ex) { /* ignored */ } } @@ -561,150 +566,194 @@ protected JWTAuthenticationResponse authenticate(String authorizationHeader) { String jwtCompact = parseAuthorizationHeader(authorizationHeader); if (jwtCompact != null) { try { + // Pre-parse JWT (without signature verification) to get issuer and check algorithm + SignedJWT signedJWT; try { - JwtClaims jwtClaims = jwtConsumer.processToClaims(jwtCompact); - String principal = jwtClaims.getStringClaimValue(principalClaim); - if (principal == null || principal.isEmpty()) { - return new JWTAuthenticationResponse( - AuthCode.PRINCIPAL_MISSING, - "Cannot identify principal from JWT. Required claim " - + principalClaim - + " missing. Cannot authenticate"); + signedJWT = SignedJWT.parse(jwtCompact); + } catch (ParseException e) { + if (e.getMessage() != null + && e.getMessage().contains("Invalid JOSE Compact Serialization")) { + return new JWTAuthenticationResponse(AuthCode.JWT_PARSE_ERROR, e.getMessage()); } - if (claimsMatchCompiled != null) { - for (Map.Entry entry : claimsMatchCompiled.entrySet()) { - String claim = entry.getKey(); - if (jwtClaims.hasClaim(claim)) { - Object claimValue = jwtClaims.getClaimValue(claim); - String claimValueStr = (claimValue != null) ? String.valueOf(claimValue) : ""; - if (!entry.getValue().matcher(claimValueStr).matches()) { - return new JWTAuthenticationResponse( - AuthCode.CLAIM_MISMATCH, - "Claim " - + claim - + "=" - + claimValueStr - + " does not match required regular expression " - + entry.getValue().pattern()); - } - } else { - return new JWTAuthenticationResponse( - AuthCode.CLAIM_MISMATCH, - "Claim " + claim + " is required but does not exist in JWT"); - } - } + return new JWTAuthenticationResponse( + AuthCode.JWT_PARSE_ERROR, "Failed to parse JWT: " + e.getMessage()); + } + + // Check algorithm allowlist before full processing + if (algAllowlist != null + && !algAllowlist.contains(signedJWT.getHeader().getAlgorithm().getName())) { + return new JWTAuthenticationResponse( + AuthCode.JWT_VALIDATION_EXCEPTION, + new RuntimeException( + "Algorithm " + + signedJWT.getHeader().getAlgorithm() + + " is not a permitted algorithm")); + } + + // Extract unverified issuer to pass to key selector via context + String tokenIssuer; + try { + tokenIssuer = signedJWT.getJWTClaimsSet().getIssuer(); + } catch (ParseException e) { + return new JWTAuthenticationResponse( + AuthCode.JWT_PARSE_ERROR, "Malformed claim: " + e.getMessage()); + } + + JWTClaimsSet jwtClaims; + try { + // Use the already-parsed SignedJWT to avoid redundant parsing and ParseException + jwtClaims = + jwtProcessor.process( + signedJWT, new IssuerAwareJWSKeySelector.IssuerContext(tokenIssuer)); + } catch (BadJWSException e) { + return new JWTAuthenticationResponse(AuthCode.SIGNATURE_INVALID, e); + } catch (BadJWTException e) { + String msg = e.getMessage(); + if (msg != null && (msg.startsWith("Expired JWT") || msg.contains("expir"))) { + return new JWTAuthenticationResponse( + AuthCode.JWT_EXPIRED, "Authentication failed due to expired JWT token. " + msg); } - if (!requiredScopes.isEmpty() && !jwtClaims.hasClaim(CLAIM_SCOPE)) { - // Fail if we require scopes but they don't exist + return new JWTAuthenticationResponse(AuthCode.JWT_VALIDATION_EXCEPTION, e); + } catch (BadJOSEException e) { + return new JWTAuthenticationResponse(AuthCode.JWT_VALIDATION_EXCEPTION, e); + } catch (JOSEException e) { + return new JWTAuthenticationResponse(AuthCode.JWT_VALIDATION_EXCEPTION, e); + } + + // Validate issuer value whenever the token carries an iss claim and at least one issuer + // is explicitly configured. requireIssuer controls whether iss must be present, not + // whether a mismatching value should be silently accepted when it is present. + if (tokenIssuer != null && issuerConfigs.stream().anyMatch(ic -> ic.getIss() != null)) { + boolean issuerMatched = + issuerConfigs.stream() + .anyMatch(ic -> ic.getIss() != null && ic.getIss().equals(tokenIssuer)); + if (!issuerMatched) { return new JWTAuthenticationResponse( - AuthCode.CLAIM_MISMATCH, - "Claim " + CLAIM_SCOPE + " is required but does not exist in JWT"); + AuthCode.JWT_VALIDATION_EXCEPTION, + new RuntimeException( + "Token issuer '" + tokenIssuer + "' does not match any configured issuer")); } + } - // Find scopes for user - Set scopes = Set.of(); - Object scopesObj = jwtClaims.getClaimValue(CLAIM_SCOPE); - if (scopesObj != null) { - if (scopesObj instanceof String) { - scopes = new HashSet<>(Arrays.asList(((String) scopesObj).split("\\s+"))); - } else if (scopesObj instanceof List) { - scopes = new HashSet<>(jwtClaims.getStringListClaimValue(CLAIM_SCOPE)); - } - // Validate that at least one of the required scopes are present in the scope claim - if (!requiredScopes.isEmpty()) { - if (scopes.stream().noneMatch(requiredScopes::contains)) { + String principal = jwtClaims.getStringClaim(principalClaim); + if (principal == null || principal.isEmpty()) { + return new JWTAuthenticationResponse( + AuthCode.PRINCIPAL_MISSING, + "Cannot identify principal from JWT. Required claim " + + principalClaim + + " missing. Cannot authenticate"); + } + if (claimsMatchCompiled != null) { + for (Map.Entry entry : claimsMatchCompiled.entrySet()) { + String claim = entry.getKey(); + if (jwtClaims.getClaim(claim) != null) { + Object claimValue = jwtClaims.getClaim(claim); + String claimValueStr = (claimValue != null) ? String.valueOf(claimValue) : ""; + if (!entry.getValue().matcher(claimValueStr).matches()) { return new JWTAuthenticationResponse( - AuthCode.SCOPE_MISSING, + AuthCode.CLAIM_MISMATCH, "Claim " - + CLAIM_SCOPE - + " does not contain any of the required scopes: " - + requiredScopes); + + claim + + "=" + + claimValueStr + + " does not match required regular expression " + + entry.getValue().pattern()); } + } else { + return new JWTAuthenticationResponse( + AuthCode.CLAIM_MISMATCH, + "Claim " + claim + " is required but does not exist in JWT"); } } + } + if (!requiredScopes.isEmpty() && jwtClaims.getClaim(CLAIM_SCOPE) == null) { + return new JWTAuthenticationResponse( + AuthCode.CLAIM_MISMATCH, + "Claim " + CLAIM_SCOPE + " is required but does not exist in JWT"); + } - // Determine roles of user, either from 'rolesClaim' or from 'scope' as parsed above - final Set finalRoles = new HashSet<>(); - if (rolesClaim == null) { - // Pass scopes with principal to signal to any Authorization plugins that user has - // some verified role claims - finalRoles.addAll(scopes); - finalRoles.remove("openid"); // Remove standard scope - } else { - // Pull roles from separate claim, either as whitespace separated list or as JSON - // array - Object rolesObj = jwtClaims.getClaimValue(rolesClaim); - if (rolesObj == null && rolesClaim.indexOf('.') > 0) { - // support map resolution of nested values - String[] nestedKeys = rolesClaim.split("\\."); - rolesObj = jwtClaims.getClaimValue(nestedKeys[0]); - for (int i = 1; i < nestedKeys.length; i++) { - if (rolesObj instanceof Map) { - String key = nestedKeys[i]; - rolesObj = ((Map) rolesObj).get(key); - } - } + // Find scopes for user + Set scopes = Set.of(); + Object scopesObj = jwtClaims.getClaim(CLAIM_SCOPE); + if (scopesObj != null) { + if (scopesObj instanceof String) { + scopes = new HashSet<>(Arrays.asList(((String) scopesObj).split("\\s+"))); + } else if (scopesObj instanceof List) { + scopes = new HashSet<>(jwtClaims.getStringListClaim(CLAIM_SCOPE)); + } + // Validate that at least one of the required scopes are present in the scope claim + if (!requiredScopes.isEmpty()) { + if (scopes.stream().noneMatch(requiredScopes::contains)) { + return new JWTAuthenticationResponse( + AuthCode.SCOPE_MISSING, + "Claim " + + CLAIM_SCOPE + + " does not contain any of the required scopes: " + + requiredScopes); } + } + } - if (rolesObj != null) { - if (rolesObj instanceof String) { - finalRoles.addAll(Arrays.asList(((String) rolesObj).split("\\s+"))); - } else if (rolesObj instanceof List) { - ((List) rolesObj) - .forEach( - entry -> { - if (entry instanceof String) { - finalRoles.add((String) entry); - } else { - throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, - String.format( - Locale.ROOT, - "Could not parse roles from JWT claim %s; expected array of strings, got array with a value of type %s", - rolesClaim, - entry.getClass().getSimpleName())); - } - }); - } else { - throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, - String.format( - Locale.ROOT, - "Could not parse roles from JWT claim %s; got %s", - rolesClaim, - rolesObj.getClass().getSimpleName())); + // Determine roles of user, either from 'rolesClaim' or from 'scope' as parsed above + final Set finalRoles = new HashSet<>(); + if (rolesClaim == null) { + finalRoles.addAll(scopes); + finalRoles.remove("openid"); // Remove standard scope + } else { + Object rolesObj = jwtClaims.getClaim(rolesClaim); + if (rolesObj == null && rolesClaim.indexOf('.') > 0) { + // support map resolution of nested values + String[] nestedKeys = rolesClaim.split("\\."); + rolesObj = jwtClaims.getClaim(nestedKeys[0]); + for (int i = 1; i < nestedKeys.length; i++) { + if (rolesObj instanceof Map) { + String key = nestedKeys[i]; + rolesObj = ((Map) rolesObj).get(key); } } } - if (!finalRoles.isEmpty()) { - return new JWTAuthenticationResponse( - AuthCode.AUTHENTICATED, - new JWTPrincipalWithUserRoles( - principal, jwtCompact, jwtClaims.getClaimsMap(), finalRoles)); - } else { - return new JWTAuthenticationResponse( - AuthCode.AUTHENTICATED, - new JWTPrincipal(principal, jwtCompact, jwtClaims.getClaimsMap())); - } - } catch (InvalidJwtSignatureException ise) { - return new JWTAuthenticationResponse(AuthCode.SIGNATURE_INVALID, ise); - } catch (InvalidJwtException e) { - // Whether or not the JWT has expired being one common reason for invalidity - if (e.hasExpired()) { - return new JWTAuthenticationResponse( - AuthCode.JWT_EXPIRED, - "Authentication failed due to expired JWT token. Expired at " - + e.getJwtContext().getJwtClaims().getExpirationTime()); - } - if (e.getCause() != null - && e.getCause() instanceof JoseException - && e.getCause().getMessage().contains("Invalid JOSE Compact Serialization")) { - return new JWTAuthenticationResponse( - AuthCode.JWT_PARSE_ERROR, e.getCause().getMessage()); + + if (rolesObj != null) { + if (rolesObj instanceof String) { + finalRoles.addAll(Arrays.asList(((String) rolesObj).split("\\s+"))); + } else if (rolesObj instanceof List) { + ((List) rolesObj) + .forEach( + entry -> { + if (entry instanceof String) { + finalRoles.add((String) entry); + } else { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + String.format( + Locale.ROOT, + "Could not parse roles from JWT claim %s; expected array of strings, got array with a value of type %s", + rolesClaim, + entry.getClass().getSimpleName())); + } + }); + } else { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + String.format( + Locale.ROOT, + "Could not parse roles from JWT claim %s; got %s", + rolesClaim, + rolesObj.getClass().getSimpleName())); + } } - return new JWTAuthenticationResponse(AuthCode.JWT_VALIDATION_EXCEPTION, e); } - } catch (MalformedClaimException e) { + if (!finalRoles.isEmpty()) { + return new JWTAuthenticationResponse( + AuthCode.AUTHENTICATED, + new JWTPrincipalWithUserRoles( + principal, jwtCompact, jwtClaims.getClaims(), finalRoles)); + } else { + return new JWTAuthenticationResponse( + AuthCode.AUTHENTICATED, + new JWTPrincipal(principal, jwtCompact, jwtClaims.getClaims())); + } + } catch (ParseException e) { return new JWTAuthenticationResponse( AuthCode.JWT_PARSE_ERROR, "Malformed claim, error was: " + e.getMessage()); } @@ -736,43 +785,37 @@ private String parseAuthorizationHeader(String authorizationHeader) { } private void initConsumer() { - JwtConsumerBuilder jwtConsumerBuilder = - new JwtConsumerBuilder() - .setAllowedClockSkewInSeconds( - 30); // allow some leeway in validating time based claims to account for clock skew - String[] issuers = - issuerConfigs.stream() - .map(JWTIssuerConfig::getIss) - .filter(Objects::nonNull) - .toArray(String[]::new); - if (issuers.length > 0) { - jwtConsumerBuilder.setExpectedIssuers( - requireIssuer, issuers); // whom the JWT needs to have been issued by + DefaultJWTProcessor processor = new DefaultJWTProcessor<>(); + processor.setJWSKeySelector(verificationKeyResolver); + + // Build the set of required claims + Set requiredClaims = new HashSet<>(); + if (requireExpirationTime) { + requiredClaims.add("exp"); + } + if (requireIssuer) { + requiredClaims.add("iss"); } - String[] audiences = + + // Build expected audiences + Set audienceSet = issuerConfigs.stream() .map(JWTIssuerConfig::getAud) .filter(Objects::nonNull) - .toArray(String[]::new); - if (audiences.length > 0) { - jwtConsumerBuilder.setExpectedAudience(audiences); // to whom the JWT is intended for - } else { - jwtConsumerBuilder.setSkipDefaultAudienceValidation(); - } - if (requireExpirationTime) jwtConsumerBuilder.setRequireExpirationTime(); - if (algAllowlist != null) - jwtConsumerBuilder - .setJwsAlgorithmConstraints( // only allow the expected signature algorithm(s) in the - // given context - new AlgorithmConstraints( - AlgorithmConstraints.ConstraintType.PERMIT, algAllowlist.toArray(new String[0]))); - jwtConsumerBuilder.setVerificationKeyResolver(verificationKeyResolver); - jwtConsumer = jwtConsumerBuilder.build(); // create the JwtConsumer instance + .collect(Collectors.toSet()); + + DefaultJWTClaimsVerifier verifier = + new DefaultJWTClaimsVerifier<>( + audienceSet.isEmpty() ? null : audienceSet, null, requiredClaims, Set.of()); + verifier.setMaxClockSkew(30); + processor.setJWTClaimsSetVerifier(verifier); + + jwtProcessor = processor; } @Override public void close() throws IOException { - jwtConsumer = null; + jwtProcessor = null; super.close(); } @@ -867,7 +910,7 @@ protected static class JWTAuthenticationResponse { private final Principal principal; private String errorMessage; private final AuthCode authCode; - private InvalidJwtException jwtException; + private Exception jwtException; enum AuthCode { PASS_THROUGH( @@ -897,7 +940,7 @@ public String getMsg() { } } - JWTAuthenticationResponse(AuthCode authCode, InvalidJwtException e) { + JWTAuthenticationResponse(AuthCode authCode, Exception e) { this.authCode = authCode; this.jwtException = e; principal = null; @@ -932,7 +975,7 @@ String getErrorMessage() { return errorMessage; } - InvalidJwtException getJwtException() { + Exception getJwtException() { return jwtException; } diff --git a/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTIssuerConfig.java b/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTIssuerConfig.java index d8a6934317a2..652936f558a9 100644 --- a/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTIssuerConfig.java +++ b/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTIssuerConfig.java @@ -18,6 +18,11 @@ package org.apache.solr.security.jwt; import com.google.common.annotations.VisibleForTesting; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.util.DefaultResourceRetriever; +import com.nimbusds.jose.util.Resource; +import com.nimbusds.jose.util.ResourceRetriever; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; @@ -26,9 +31,14 @@ import java.net.MalformedURLException; import java.net.URI; import java.net.URL; +import java.net.URLConnection; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyStore; import java.security.cert.X509Certificate; +import java.text.ParseException; +import java.time.Instant; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; @@ -36,16 +46,14 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManagerFactory; import org.apache.solr.common.SolrException; import org.apache.solr.common.util.EnvUtils; import org.apache.solr.common.util.StrUtils; import org.apache.solr.common.util.Utils; -import org.jose4j.http.Get; -import org.jose4j.http.SimpleResponse; -import org.jose4j.jwk.HttpsJwks; -import org.jose4j.jwk.JsonWebKey; -import org.jose4j.jwk.JsonWebKeySet; -import org.jose4j.lang.JoseException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -66,10 +74,10 @@ public class JWTIssuerConfig { private static HttpsJwksFactory httpsJwksFactory = new HttpsJwksFactory(3600, 5000); private String iss; private String aud; - private JsonWebKeySet jsonWebKeySet; + private JWKSet jsonWebKeySet; private String name; private List jwksUrl; - private List httpsJwks; + private List httpsJwks; private String wellKnownUrl; private WellKnownDiscoveryConfig wellKnownDiscoveryConfig; private String clientId; @@ -183,7 +191,7 @@ protected void parseConfigMap(Map configMap) { } /** - * Setter that takes a jwk config object, parses it into a {@link JsonWebKeySet} and sets it + * Setter that takes a jwk config object, parses it into a {@link JWKSet} and sets it * * @param jwksObject the config object to parse */ @@ -193,7 +201,7 @@ protected void setJsonWebKeySet(Object jwksObject) { if (jwksObject != null) { jsonWebKeySet = parseJwkSet((Map) jwksObject); } - } catch (JoseException e) { + } catch (ParseException e) { throw new SolrException( SolrException.ErrorCode.SERVER_ERROR, "Failed parsing parameter 'jwk' for issuer " + getName(), @@ -202,17 +210,13 @@ protected void setJsonWebKeySet(Object jwksObject) { } @SuppressWarnings("unchecked") - protected static JsonWebKeySet parseJwkSet(Map jwkObj) throws JoseException { - JsonWebKeySet webKeySet = new JsonWebKeySet(); + protected static JWKSet parseJwkSet(Map jwkObj) throws ParseException { + String json = Utils.toJSONString(jwkObj); if (jwkObj.containsKey("keys")) { - List jwkList = (List) jwkObj.get("keys"); - for (Object jwkO : jwkList) { - webKeySet.addJsonWebKey(JsonWebKey.Factory.newJwk((Map) jwkO)); - } + return JWKSet.parse(json); } else { - webKeySet = new JsonWebKeySet(JsonWebKey.Factory.newJwk(jwkObj)); + return new JWKSet(JWK.parse(json)); } - return webKeySet; } private WellKnownDiscoveryConfig fetchWellKnown(URL wellKnownUrl) { @@ -273,7 +277,7 @@ else if (jwksUrlListOrString != null) return this; } - public List getHttpsJwks() { + public List getHttpsJwks() { if (httpsJwks == null) { httpsJwks = httpsJwksFactory.createList(getJwksUrls()); } @@ -281,7 +285,7 @@ public List getHttpsJwks() { } /** - * Set the factory to use when creating HttpsJwks objects + * Set the factory to use when creating JwkSetFetcher objects * * @param httpsJwksFactory factory with custom settings */ @@ -289,11 +293,11 @@ public static void setHttpsJwksFactory(HttpsJwksFactory httpsJwksFactory) { JWTIssuerConfig.httpsJwksFactory = httpsJwksFactory; } - public JsonWebKeySet getJsonWebKeySet() { + public JWKSet getJsonWebKeySet() { return jsonWebKeySet; } - public JWTIssuerConfig setJsonWebKeySet(JsonWebKeySet jsonWebKeySet) { + public JWTIssuerConfig setJsonWebKeySet(JWKSet jsonWebKeySet) { this.jsonWebKeySet = jsonWebKeySet; return this; } @@ -385,7 +389,11 @@ public Map asConfig() { putIfNotNull(config, PARAM_TOKEN_ENDPOINT, tokenEndpoint); putIfNotNull(config, PARAM_AUTHORIZATION_FLOW, authorizationFlow); if (jsonWebKeySet != null) { - putIfNotNull(config, PARAM_JWK, jsonWebKeySet.getJsonWebKeys()); + Map jwkSetMap = new HashMap<>(); + jwkSetMap.put( + "keys", + jsonWebKeySet.getKeys().stream().map(JWK::toJSONObject).collect(Collectors.toList())); + putIfNotNull(config, PARAM_JWK, jwkSetMap); } return config; } @@ -424,14 +432,6 @@ public boolean isValid() { return jwkConfigured > 0; } - private static void disableHostVerificationIfLocalhost(URL url, Get httpGet) { - InetAddress loopbackAddress = InetAddress.getLoopbackAddress(); - if (loopbackAddress.getCanonicalHostName().equals(url.getHost()) - || loopbackAddress.getHostName().equals(url.getHost())) { - httpGet.setHostnameVerifier((hostname, session) -> true); - } - } - public void setTrustedCerts(Collection trustedCerts) { this.trustedCerts = trustedCerts; } @@ -441,6 +441,110 @@ public Collection getTrustedCerts() { return this.trustedCerts; } + /** Builds an SSL socket factory trusting the given certificates. */ + static SSLSocketFactory buildSSLSocketFactory(Collection trustedCerts) { + try { + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + int i = 0; + for (X509Certificate cert : trustedCerts) { + ks.setCertificateEntry("trusted-cert-" + i++, cert); + } + TrustManagerFactory tmf = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(ks); + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, tmf.getTrustManagers(), null); + return sslContext.getSocketFactory(); + } catch (GeneralSecurityException | IOException e) { + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, "Failed to build custom SSL context", e); + } + } + + /** + * Builds a ResourceRetriever with an optional custom SSL trust store. Uses a custom + * implementation when trusted certs are configured; in that case hostname verification is also + * bypassed for loopback hosts. Otherwise uses the default DefaultResourceRetriever. + */ + static ResourceRetriever buildResourceRetriever( + Collection trustedCerts, URL url) { + if (trustedCerts == null) { + return new DefaultResourceRetriever(); + } + SSLSocketFactory ssf = buildSSLSocketFactory(trustedCerts); + InetAddress loopback = InetAddress.getLoopbackAddress(); + boolean disableHostnameVerification = + loopback.getCanonicalHostName().equals(url.getHost()) + || loopback.getHostName().equals(url.getHost()); + return resourceUrl -> { + URLConnection conn = resourceUrl.openConnection(); + if (conn instanceof HttpsURLConnection httpsConn) { + httpsConn.setSSLSocketFactory(ssf); + if (disableHostnameVerification) { + httpsConn.setHostnameVerifier((hostname, session) -> true); + } + } + try (InputStream in = conn.getInputStream()) { + String content = new String(in.readAllBytes(), StandardCharsets.UTF_8); + return new Resource(content, conn.getContentType()); + } + }; + } + + /** Fetches and caches a JWK set from a remote URL using nimbus-jose-jwt's ResourceRetriever. */ + public static class JwkSetFetcher { + private final String url; + private final ResourceRetriever retriever; + private final long cacheDurationSeconds; + private final long refreshReprieveThresholdMs; + private JWKSet cachedSet; + private Instant cacheExpiry = Instant.EPOCH; + private Instant lastRefreshTime = Instant.EPOCH; + + JwkSetFetcher( + String url, + ResourceRetriever retriever, + long cacheDurationSeconds, + long refreshReprieveThresholdMs) { + this.url = url; + this.retriever = retriever; + this.cacheDurationSeconds = cacheDurationSeconds; + this.refreshReprieveThresholdMs = refreshReprieveThresholdMs; + } + + public synchronized List getKeys() throws IOException, ParseException { + if (cachedSet == null || Instant.now().isAfter(cacheExpiry)) { + refresh(); + } + return cachedSet.getKeys(); + } + + /** + * Fetches fresh keys from the remote JWK endpoint. Calls within the refresh reprieve window are + * ignored to avoid hammering the IdP on repeated unknown-key requests. + */ + public synchronized void refresh() throws IOException, ParseException { + Instant now = Instant.now(); + if (cachedSet != null + && now.isBefore(lastRefreshTime.plusMillis(refreshReprieveThresholdMs))) { + return; + } + try { + Resource resource = retriever.retrieveResource(URI.create(url).toURL()); + cachedSet = JWKSet.parse(resource.getContent()); + cacheExpiry = now.plusSeconds(cacheDurationSeconds); + lastRefreshTime = now; + } catch (MalformedURLException e) { + throw new IOException("Malformed JWK URL: " + url, e); + } + } + + public String getLocation() { + return url; + } + } + public static class HttpsJwksFactory { private final long jwkCacheDuration; private final long refreshReprieveThreshold; @@ -460,12 +564,7 @@ public HttpsJwksFactory( this.trustedCerts = trustedCerts; } - /* - * While the class name is HttpsJwks, it actually works with plain http formatted url as well. - * - * @param url the Url to connect to for JWK details. - */ - private HttpsJwks create(String url) { + private JwkSetFetcher create(String url) { final URL jwksUrl; try { jwksUrl = URI.create(url).toURL(); @@ -475,19 +574,12 @@ private HttpsJwks create(String url) { SolrException.ErrorCode.SERVER_ERROR, "Url " + url + " configured in " + PARAM_JWKS_URL + " is not a valid URL"); } - HttpsJwks httpsJkws = new HttpsJwks(url); - httpsJkws.setDefaultCacheDuration(jwkCacheDuration); - httpsJkws.setRefreshReprieveThreshold(refreshReprieveThreshold); - if (trustedCerts != null) { - Get getWithCustomTrust = new Get(); - getWithCustomTrust.setTrustedCertificates(trustedCerts); - disableHostVerificationIfLocalhost(jwksUrl, getWithCustomTrust); - httpsJkws.setSimpleHttpGet(getWithCustomTrust); - } - return httpsJkws; + + ResourceRetriever retriever = buildResourceRetriever(trustedCerts, jwksUrl); + return new JwkSetFetcher(url, retriever, jwkCacheDuration, refreshReprieveThreshold); } - public List createList(List jwkUrls) { + public List createList(List jwkUrls) { return jwkUrls.stream().map(this::create).collect(Collectors.toList()); } } @@ -529,13 +621,10 @@ public static WellKnownDiscoveryConfig parse( if ("file".equals(url.getProtocol())) { return parse(url.openStream()); } else { - Get httpGet = new Get(); - if (trustedCerts != null) { - httpGet.setTrustedCertificates(trustedCerts); - disableHostVerificationIfLocalhost(url, httpGet); - } - SimpleResponse resp = httpGet.get(url.toString()); - return parse(new ByteArrayInputStream(resp.getBody().getBytes(StandardCharsets.UTF_8))); + ResourceRetriever retriever = buildResourceRetriever(trustedCerts, url); + Resource resp = retriever.retrieveResource(url); + return parse( + new ByteArrayInputStream(resp.getContent().getBytes(StandardCharsets.UTF_8))); } } catch (IOException e) { throw new SolrException( diff --git a/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTVerificationkeyResolver.java b/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTVerificationkeyResolver.java deleted file mode 100644 index 2fe75a1baf5c..000000000000 --- a/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTVerificationkeyResolver.java +++ /dev/null @@ -1,183 +0,0 @@ -/* - * 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.jwt; - -import java.io.IOException; -import java.lang.invoke.MethodHandles; -import java.security.Key; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import javax.net.ssl.SSLHandshakeException; -import org.apache.solr.common.SolrException; -import org.jose4j.jwk.HttpsJwks; -import org.jose4j.jwk.JsonWebKey; -import org.jose4j.jwk.VerificationJwkSelector; -import org.jose4j.jws.JsonWebSignature; -import org.jose4j.jwt.JwtClaims; -import org.jose4j.jwt.MalformedClaimException; -import org.jose4j.jwt.consumer.InvalidJwtException; -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; - -/** - * Resolves jws signature verification keys from a set of {@link JWTIssuerConfig} objects, which may - * represent any valid configuration in Solr's security.json, i.e. static list of JWKs or keys - * retrieved from HTTPs JWK endpoints. - * - *

This implementation maintains a map of issuers, each with its own list of {@link JsonWebKey}, - * and resolves correct key from correct issuer similar to HttpsJwksVerificationKeyResolver. If - * issuer claim is not required, we will select the first IssuerConfig if there is exactly one such - * config. - * - *

If a key is not found, and issuer is backed by HTTPsJWKs, we attempt one cache refresh before - * failing. - */ -public class JWTVerificationkeyResolver implements VerificationKeyResolver { - private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); - - private final VerificationJwkSelector verificationJwkSelector = new VerificationJwkSelector(); - - private final Map issuerConfigs = new HashMap<>(); - private final boolean requireIssuer; - - /** - * Resolves key from a JWKs from one or more IssuerConfigs - * - * @param issuerConfigs Collection of configuration objects for the issuer(s) - * @param requireIssuer if true, will require 'iss' claim on jws - */ - public JWTVerificationkeyResolver( - Collection issuerConfigs, boolean requireIssuer) { - this.requireIssuer = requireIssuer; - issuerConfigs.forEach(ic -> this.issuerConfigs.put(ic.getIss(), ic)); - } - - @Override - public Key resolveKey(JsonWebSignature jws, List nestingContext) - throws UnresolvableKeyException { - JsonWebKey theChosenOne; - List jsonWebKeys = new ArrayList<>(); - - String keysSource = "N/A"; - try { - String tokenIssuer = JwtClaims.parse(jws.getUnverifiedPayload()).getIssuer(); - JWTIssuerConfig issuerConfig; - if (tokenIssuer == null) { - if (requireIssuer) { - throw new UnresolvableKeyException("Token does not contain required issuer claim"); - } else if (issuerConfigs.size() == 1) { - issuerConfig = issuerConfigs.values().iterator().next(); - } else { - throw new SolrException( - SolrException.ErrorCode.SERVER_ERROR, - "Signature verification not supported for multiple issuers without 'iss' claim in token."); - } - } else { - issuerConfig = issuerConfigs.get(tokenIssuer); - if (issuerConfig == null) { - if (issuerConfigs.size() > 1) { - throw new UnresolvableKeyException( - "No issuers configured for iss='" + tokenIssuer + "', cannot validate signature"); - } else if (issuerConfigs.size() == 1) { - issuerConfig = issuerConfigs.values().iterator().next(); - log.debug( - "No issuer matching token's iss claim, but exactly one configured, selecting that one"); - } else { - throw new SolrException( - SolrException.ErrorCode.SERVER_ERROR, - "Signature verification failed due to no configured issuer with id " + tokenIssuer); - } - } - } - - // Add all keys into a leader list - if (issuerConfig.usesHttpsJwk()) { - keysSource = "[" + String.join(", ", issuerConfig.getJwksUrls()) + "]"; - for (HttpsJwks hjwks : issuerConfig.getHttpsJwks()) { - try { - jsonWebKeys.addAll(hjwks.getJsonWebKeys()); - } catch (SSLHandshakeException e) { - throw new SolrException( - SolrException.ErrorCode.SERVER_ERROR, - "Failed to connect with " - + hjwks.getLocation() - + ", do you have the correct SSL certificate configured?", - e); - } - } - } else { - keysSource = "static list of keys in security.json"; - jsonWebKeys.addAll(issuerConfig.getJsonWebKeySet().getJsonWebKeys()); - } - - theChosenOne = verificationJwkSelector.select(jws, jsonWebKeys); - if (theChosenOne == null && issuerConfig.usesHttpsJwk()) { - if (log.isDebugEnabled()) { - 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 | InvalidJwtException | MalformedClaimException e) { - String msg = - String.format( - Locale.ROOT, - "Unable to find a suitable verification key for JWS w/ header %s due to an unexpected exception (%s) " - + "while obtaining or using keys from source %s", - jws.getHeaders().getFullHeaderAsJsonString(), - e, - keysSource); - throw new UnresolvableKeyException(msg, e); - } - - if (theChosenOne == null) { - String msg = - String.format( - Locale.ROOT, - "Unable to find a suitable verification key for JWS w/ header %s from %d keys from source %s", - jws.getHeaders().getFullHeaderAsJsonString(), - jsonWebKeys.size(), - keysSource); - throw new UnresolvableKeyException(msg); - } - - return theChosenOne.getKey(); - } - - Set getIssuerConfigs() { - return new HashSet<>(issuerConfigs.values()); - } -} diff --git a/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/IssuerAwareJWSKeySelectorTest.java b/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/IssuerAwareJWSKeySelectorTest.java new file mode 100644 index 000000000000..13f68d54bac8 --- /dev/null +++ b/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/IssuerAwareJWSKeySelectorTest.java @@ -0,0 +1,223 @@ +/* + * 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.jwt; + +import static java.util.Arrays.asList; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.when; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.KeySourceException; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.ECKeyGenerator; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.SignedJWT; +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.security.jwt.JWTIssuerConfig.HttpsJwksFactory; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +/** Tests the multi jwks resolver that can fetch keys from multiple JWKs */ +@SuppressWarnings("ArraysAsListWithZeroOrOneArgument") +public class IssuerAwareJWSKeySelectorTest extends SolrTestCaseJ4 { + private IssuerAwareJWSKeySelector resolver; + + @Rule public MockitoRule mockitoRule = MockitoJUnit.rule(); + + @Mock private JWTIssuerConfig.JwkSetFetcher firstJwkList; + @Mock private JWTIssuerConfig.JwkSetFetcher 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; + + @Override + @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.getKeys()).thenReturn(asList(k1.getJwk(), k2.getJwk())); + doAnswer( + invocation -> { + keysToReturnFromSecondJwk = refreshSequenceForSecondJwk.next(); + System.out.println("Refresh called, next to return is " + keysToReturnFromSecondJwk); + return null; + }) + .when(secondJwkList) + .refresh(); + when(secondJwkList.getKeys()) + .then( + inv -> { + if (keysToReturnFromSecondJwk == null) + keysToReturnFromSecondJwk = refreshSequenceForSecondJwk.next(); + return keysToReturnFromSecondJwk; + }); + when(httpsJwksFactory.createList(ArgumentMatchers.anyList())) + .thenReturn(asList(firstJwkList, secondJwkList)); + + JWTIssuerConfig issuerConfig = + new JWTIssuerConfig("primary").setIss("foo").setJwksUrl(asList("url1", "url2")); + JWTIssuerConfig.setHttpsJwksFactory(httpsJwksFactory); + resolver = new IssuerAwareJWSKeySelector(Arrays.asList(issuerConfig), true); + + assumeWorkingMockito(); + } + + @Test + public void findKeyFromFirstList() throws Exception { + refreshSequenceForSecondJwk = + asList(asList(k3.getJwk(), k4.getJwk()), asList(k5.getJwk())).iterator(); + resolver.selectJWSKeys(k1.getJwsHeader(), new IssuerAwareJWSKeySelector.IssuerContext("foo")); + resolver.selectJWSKeys(k2.getJwsHeader(), new IssuerAwareJWSKeySelector.IssuerContext("foo")); + resolver.selectJWSKeys(k3.getJwsHeader(), new IssuerAwareJWSKeySelector.IssuerContext("foo")); + resolver.selectJWSKeys(k4.getJwsHeader(), new IssuerAwareJWSKeySelector.IssuerContext("foo")); + // Key k5 is not in cache, so a refresh will be done + resolver.selectJWSKeys(k5.getJwsHeader(), new IssuerAwareJWSKeySelector.IssuerContext("foo")); + } + + @Test(expected = KeySourceException.class) + public void notFoundKey() throws Exception { + 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.selectJWSKeys(k5.getJwsHeader(), new IssuerAwareJWSKeySelector.IssuerContext("foo")); + } + + @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 IssuerAwareJWSKeySelector(Arrays.asList(singleIssuerConfig), false); + + List keys = + resolver.selectJWSKeys( + k1.getJwsHeader(), new IssuerAwareJWSKeySelector.IssuerContext(null)); + assertFalse(keys.isEmpty()); + } + + @Test(expected = KeySourceException.class) + public void noIssRequireIssuerFalseMultipleIssuersThrows() throws Exception { + // null iss, requireIssuer=false, multiple issuers → KeySourceException (ambiguous) + JWTIssuerConfig iss1 = new JWTIssuerConfig("iss1").setIss("A").setJwksUrl(asList("url1")); + JWTIssuerConfig iss2 = new JWTIssuerConfig("iss2").setIss("B").setJwksUrl(asList("url2")); + resolver = new IssuerAwareJWSKeySelector(Arrays.asList(iss1, iss2), false); + resolver.selectJWSKeys(k1.getJwsHeader(), new IssuerAwareJWSKeySelector.IssuerContext(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 IssuerAwareJWSKeySelector(Arrays.asList(singleIssuerConfig), true); + + List keys = + resolver.selectJWSKeys( + k1.getJwsHeader(), new IssuerAwareJWSKeySelector.IssuerContext("UNKNOWN")); + assertFalse(keys.isEmpty()); + } + + @Test(expected = KeySourceException.class) + public void issMismatchMultipleIssuersThrows() throws Exception { + // iss present but unrecognised, multiple issuers → KeySourceException + JWTIssuerConfig iss1 = new JWTIssuerConfig("iss1").setIss("A").setJwksUrl(asList("url1")); + JWTIssuerConfig iss2 = new JWTIssuerConfig("iss2").setIss("B").setJwksUrl(asList("url2")); + resolver = new IssuerAwareJWSKeySelector(Arrays.asList(iss1, iss2), true); + resolver.selectJWSKeys( + k1.getJwsHeader(), new IssuerAwareJWSKeySelector.IssuerContext("UNKNOWN")); + } + + @Test + public void ecKeyTypeMaterialisedCorrectly() throws Exception { + // EC key type should be returned as ECPublicKey, not RSAPublicKey + ECKey ecKey = new ECKeyGenerator(Curve.P_256).keyID("ec1").generate(); + JWTIssuerConfig ecIssuerConfig = + new JWTIssuerConfig("ec-issuer") + .setIss("ec-iss") + .setJsonWebKeySet(new JWKSet(ecKey.toPublicJWK())); + resolver = new IssuerAwareJWSKeySelector(Arrays.asList(ecIssuerConfig), false); + + JWSHeader ecHeader = new JWSHeader.Builder(JWSAlgorithm.ES256).keyID("ec1").build(); + List keys = + resolver.selectJWSKeys(ecHeader, new IssuerAwareJWSKeySelector.IssuerContext("ec-iss")); + assertFalse(keys.isEmpty()); + assertTrue(keys.get(0) instanceof ECPublicKey); + } + + @SuppressWarnings("NewClassNamingConvention") + public static class KeyHolder { + private final RSAKey key; + private final String kid; + + public KeyHolder(String kid) throws JOSEException { + this.kid = kid; + key = new RSAKeyGenerator(2048).keyID(kid).generate(); + } + + public RSAKey getRsaKey() { + return key; + } + + public JWK getJwk() { + return key.toPublicJWK(); + } + + public JWSHeader getJwsHeader() { + return new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(kid).build(); + } + + /** Returns a fully signed JWT for use in integration-style tests. */ + public SignedJWT getSignedJWT() throws Exception { + SignedJWT jwt = + new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(kid).build(), + JWTAuthPluginTest.generateClaims()); + jwt.sign(new RSASSASigner(key)); + return jwt; + } + } +} diff --git a/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTAuthPluginIntegrationTest.java b/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTAuthPluginIntegrationTest.java index d480bd7ead6a..d0eb9087e6a7 100644 --- a/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTAuthPluginIntegrationTest.java +++ b/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTAuthPluginIntegrationTest.java @@ -19,6 +19,12 @@ import static java.nio.charset.StandardCharsets.UTF_8; import static org.apache.solr.security.jwt.JWTAuthPluginTest.JWT_TEST_PATH; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.SignedJWT; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; @@ -62,13 +68,6 @@ import org.apache.solr.util.TimeOut; import org.eclipse.jetty.client.BytesRequestContent; import org.eclipse.jetty.client.HttpClient; -import org.jose4j.jwk.PublicJsonWebKey; -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.lang.JoseException; import org.junit.After; import org.junit.AfterClass; import org.junit.BeforeClass; @@ -89,7 +88,7 @@ public class JWTAuthPluginIntegrationTest extends SolrCloudAuthTestCase { private static Path pemFilePath; private static Path wrongPemFilePath; private static String jwtStaticTestToken; - private static JsonWebSignature jws; + private static SignedJWT jws; private static String jwtTokenWrongSignature; private static MockOAuth2Server mockOAuth2Server; @@ -335,8 +334,8 @@ public void testInternodeAuthorization() throws Exception { .intValue()); } - static String getBearerAuthHeader(JsonWebSignature jws) throws JoseException { - return "Bearer " + jws.getCompactSerialization(); + static String getBearerAuthHeader(SignedJWT jws) { + return "Bearer " + jws.serialize(); } private void assertAuthMetricsMinimums( @@ -428,24 +427,21 @@ private static void initStaticJwt() throws Exception { + " \"n\": \"jeyrvOaZrmKWjyNXt0myAc_pJ1hNt3aRupExJEx1ewPaL9J9HFgSCjMrYxCB1ETO1NDyZ3nSgjZis-jHHDqBxBjRdq_t1E2rkGFaYbxAyKt220Pwgme_SFTB9MXVrFQGkKyjmQeVmOmV6zM3KK8uMdKQJ4aoKmwBcF5Zg7EZdDcKOFgpgva1Jq-FlEsaJ2xrYDYo3KnGcOHIt9_0NQeLsqZbeWYLxYni7uROFncXYV5FhSJCeR4A_rrbwlaCydGxE0ToC_9HNYibUHlkJjqyUhAgORCbNS8JLCJH8NUi5sDdIawK9GTSyvsJXZ-QHqo4cMUuxWV5AJtaRGghuMUfqQ\"\n" + "}"; - PublicJsonWebKey jwk = RsaJsonWebKey.Factory.newPublicJwk(jwkJSON); - JwtClaims claims = JWTAuthPluginTest.generateClaims(); - jws = new JsonWebSignature(); - jws.setPayload(claims.toJson()); - jws.setKey(jwk.getPrivateKey()); - jws.setKeyIdHeaderValue(jwk.getKeyId()); - jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256); - - jwtStaticTestToken = jws.getCompactSerialization(); - - PublicJsonWebKey jwk2 = RsaJwkGenerator.generateJwk(2048); - jwk2.setKeyId("k2"); - JsonWebSignature jws2 = new JsonWebSignature(); - jws2.setPayload(claims.toJson()); - jws2.setKey(jwk2.getPrivateKey()); - jws2.setKeyIdHeaderValue(jwk2.getKeyId()); - jws2.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256); - jwtTokenWrongSignature = jws2.getCompactSerialization(); + RSAKey jwk = RSAKey.parse(jwkJSON); + jws = + new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(jwk.getKeyID()).build(), + JWTAuthPluginTest.generateClaims()); + jws.sign(new RSASSASigner(jwk)); + jwtStaticTestToken = jws.serialize(); + + RSAKey jwk2 = new RSAKeyGenerator(2048).keyID("k2").generate(); + SignedJWT jws2 = + new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(jwk2.getKeyID()).build(), + JWTAuthPluginTest.generateClaims()); + jws2.sign(new RSASSASigner(jwk2)); + jwtTokenWrongSignature = jws2.serialize(); } private void getAndFail(String url, String token) { @@ -522,8 +518,8 @@ private void createCollection(MiniSolrCloudCluster myCluster, String collectionN myCluster.waitForActiveCollection(collectionName, 2, 2); } - private void executeCommand( - HttpClient httpClient, String url, String payload, JsonWebSignature jws) throws Exception { + private void executeCommand(HttpClient httpClient, String url, String payload, SignedJWT jws) + throws Exception { // HACK: work around for SOLR-13464... // @@ -533,7 +529,7 @@ private void executeCommand( final Set> initialPlugins = getAuthPluginsInUseForCluster(url).entrySet(); - String authHeaderValue = jws != null ? "Bearer " + jws.getCompactSerialization() : null; + String authHeaderValue = jws != null ? "Bearer " + jws.serialize() : null; var rsp = httpClient .POST(url) 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..002208f66c7a 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,12 +18,20 @@ 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; import static org.apache.solr.security.jwt.JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.PRINCIPAL_MISSING; import static org.apache.solr.security.jwt.JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.SCOPE_MISSING; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; @@ -36,10 +44,12 @@ import java.util.Arrays; import java.util.Base64; import java.util.Collection; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; import org.apache.solr.SolrTestCaseJ4; import org.apache.solr.common.SolrException; import org.apache.solr.common.util.EnvUtils; @@ -47,13 +57,6 @@ import org.apache.solr.security.VerifiedUserRoles; import org.apache.solr.servlet.LoadAdminUiServlet; import org.apache.solr.util.CryptoKeys; -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; @@ -64,7 +67,7 @@ public class JWTAuthPluginTest extends SolrTestCaseJ4 { private static String testHeader; private static String slimHeader; private JWTAuthPlugin plugin; - private static RsaJsonWebKey rsaJsonWebKey; + private static RSAKey rsaKey; private HashMap testConfig; private HashMap minimalConfig; private static String trustedPemCert; @@ -76,87 +79,88 @@ public static Path JWT_TEST_PATH() { } 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"); + rsaKey = new RSAKeyGenerator(2048).keyID("k1").generate(); 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())); + testJwk.put("kty", rsaKey.getKeyType().getValue()); + testJwk.put("e", rsaKey.getPublicExponent().toString()); + testJwk.put("use", "sig"); + testJwk.put("kid", rsaKey.getKeyID()); + testJwk.put("alg", JWSAlgorithm.RS256.getName()); + testJwk.put("n", rsaKey.getModulus().toString()); trustedPemCert = Files.readString(JWT_TEST_PATH().resolve("security").resolve("jwt_plugin_idp_cert.pem")); - } catch (JoseException | IOException e) { + } catch (Exception 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 - claims.setClaim("email_verified", true); // boolean claim as per OIDC spec - claims.setClaim("admin", false); // another boolean claim - List 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 - - // Keycloak Style resource_access roles + JWTClaimsSet claims = generateClaims(); + SignedJWT signedJWT = + new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).build(), claims); + signedJWT.sign(new RSASSASigner(rsaKey)); + String testJwt = signedJWT.serialize(); + testHeader = "Bearer " + testJwt; + + JWTClaimsSet slimClaims = generateSlimClaims(); + SignedJWT slimJwt = + new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).build(), slimClaims); + slimJwt.sign(new RSASSASigner(rsaKey)); + slimHeader = "Bearer " + slimJwt.serialize(); + } + + protected static JWTClaimsSet generateClaims() throws Exception { + Date now = new Date(); HashMap solrMap = new HashMap<>(); solrMap.put("roles", Arrays.asList("user", "admin")); HashMap resourceAccess = new HashMap<>(); resourceAccess.put("solr", solrMap); - claims.setClaim("resource_access", resourceAccess); - // Special claim with dots in key, should still be addressable non-nested - claims.setClaim("roles.with.dot", Arrays.asList("user", "admin")); - - return claims; + return new JWTClaimsSet.Builder() + .issuer("IDServer") + .audience("Solr") + .expirationTime(new Date(now.getTime() + 10 * 60 * 1000)) + .jwtID(UUID.randomUUID().toString()) + .issueTime(now) + .notBeforeTime(new Date(now.getTime() - 2 * 60 * 1000)) + .subject("solruser") + .claim("scope", "solr:read") + .claim("name", "Solr User") + .claim("customPrincipal", "custom") + .claim("claim1", "foo") + .claim("claim2", "bar") + .claim("claim3", "foo") + .claim("email_verified", true) + .claim("admin", false) + .claim("roles", Arrays.asList("group-one", "other-group", "group-three")) + .claim("resource_access", resourceAccess) + .claim("roles.with.dot", Arrays.asList("user", "admin")) + .build(); + } + + /** Slim claims: no iss, aud, exp, or sub */ + protected static JWTClaimsSet generateSlimClaims() throws Exception { + Date now = new Date(); + return new JWTClaimsSet.Builder() + .jwtID(UUID.randomUUID().toString()) + .issueTime(now) + .notBeforeTime(new Date(now.getTime() - 2 * 60 * 1000)) + .claim("scope", "solr:read") + .claim("name", "Solr User") + .claim("customPrincipal", "custom") + .claim("claim1", "foo") + .claim("claim2", "bar") + .claim("claim3", "foo") + .claim("email_verified", true) + .claim("admin", false) + .claim("roles", Arrays.asList("group-one", "other-group", "group-three")) + .build(); } @Override @@ -738,6 +742,120 @@ public void parseCertsFromFile() throws IOException { assertEquals(2, plugin.parseCertsFromFile(pemFilePath).size()); } + @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() throws Exception { + // 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. + JWTClaimsSet claims = + new JWTClaimsSet.Builder() + .issuer("IDServer") + .audience("Solr") + .expirationTime(new Date(new Date().getTime() + 10 * 60 * 1000)) + .subject("solruser") + .claim("scope", Arrays.asList("solr:read", "openid")) + .claim("customPrincipal", "custom") + .build(); + SignedJWT signedJWT = + new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).build(), claims); + signedJWT.sign(new RSASSASigner(rsaKey)); + String header = "Bearer " + signedJWT.serialize(); + + 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 asConfigJwkRoundTrip() { + // Verify that re-initialising a plugin from an issuer's asConfig() output still authenticates + Map issuerConf = plugin.getIssuerConfigs().get(0).asConfig(); + JWTAuthPlugin roundTrippedPlugin = new JWTAuthPlugin(); + HashMap newConfig = new HashMap<>(); + newConfig.put("class", "org.apache.solr.security.jwt.JWTAuthPlugin"); + newConfig.put("principalClaim", "customPrincipal"); + newConfig.put("jwk", issuerConf.get("jwk")); + roundTrippedPlugin.init(newConfig); + try { + JWTAuthPlugin.JWTAuthenticationResponse resp = roundTrippedPlugin.authenticate(testHeader); + assertTrue(resp.getErrorMessage(), resp.isAuthenticated()); + } finally { + try { + roundTrippedPlugin.close(); + } catch (Exception ignored) { + } + } + } + + @Test + public void tokenExpiredWithinClockSkewIsAuthenticated() throws Exception { + // Token expired 25 seconds ago — within the 30-second clock skew tolerance + JWTClaimsSet claims = + new JWTClaimsSet.Builder() + .issuer("IDServer") + .audience("Solr") + .expirationTime(new Date(new Date().getTime() - 25 * 1000)) + .subject("solruser") + .claim("customPrincipal", "custom") + .build(); + SignedJWT signedJWT = + new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).build(), claims); + signedJWT.sign(new RSASSASigner(rsaKey)); + String header = "Bearer " + signedJWT.serialize(); + + 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 + JWTClaimsSet claims = + new JWTClaimsSet.Builder() + .issuer("IDServer") + .audience("Solr") + .expirationTime(new Date(new Date().getTime() - 35 * 1000)) + .subject("solruser") + .claim("customPrincipal", "custom") + .build(); + SignedJWT signedJWT = + new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).build(), claims); + signedJWT.sign(new RSASSASigner(rsaKey)); + String header = "Bearer " + signedJWT.serialize(); + + JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(header); + assertFalse(resp.isAuthenticated()); + assertEquals(JWT_EXPIRED, resp.getAuthCode()); + } + @Test public void testRegisterTokenEndpointForCsp() { testConfig.put("tokenEndpoint", "http://acmepaymentscorp/oauth/oauth20/token"); 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..cf0c64843015 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 @@ -20,6 +20,8 @@ import static org.apache.solr.security.jwt.JWTAuthPluginTest.JWT_TEST_PATH; import static org.apache.solr.security.jwt.JWTAuthPluginTest.testJwk; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.util.Resource; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -29,9 +31,9 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; import org.apache.solr.SolrTestCase; import org.apache.solr.common.SolrException; -import org.jose4j.jwk.JsonWebKeySet; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -126,7 +128,7 @@ public void isValid() { @Test(expected = SolrException.class) public void notValidBothJwksAndJwk() { - testIssuer.setJsonWebKeySet(new JsonWebKeySet()); + testIssuer.setJsonWebKeySet(new JWKSet()); testIssuer.isValid(); } @@ -242,4 +244,51 @@ 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 + JWKSet result = JWTIssuerConfig.parseJwkSet(testJwk); + assertEquals(1, result.getKeys().size()); + assertEquals("k1", result.getKeys().get(0).getKeyID()); + } + + @Test + public void jwkSetFetcherRefreshReprieveSuppressesSecondCall() throws Exception { + AtomicInteger fetchCount = new AtomicInteger(); + JWKSet referenceSet = JWTIssuerConfig.parseJwkSet(testJwk); + String jwkSetJson = referenceSet.toString(); + com.nimbusds.jose.util.ResourceRetriever countingRetriever = + url -> { + fetchCount.incrementAndGet(); + return new Resource(jwkSetJson, "application/json"); + }; + // refreshReprieveThresholdMs=5000 → second call within that window is suppressed + JWTIssuerConfig.JwkSetFetcher fetcher = + new JWTIssuerConfig.JwkSetFetcher( + "https://example.com/jwks", countingRetriever, 3600L, 5000L); + + fetcher.refresh(); // First call — fetches + fetcher.refresh(); // Second call within reprieve window — suppressed + assertEquals(1, fetchCount.get()); + } + + @Test + public void jwkSetFetcherCacheExpiryTriggersRefetch() throws Exception { + AtomicInteger fetchCount = new AtomicInteger(); + JWKSet referenceSet = JWTIssuerConfig.parseJwkSet(testJwk); + String jwkSetJson = referenceSet.toString(); + com.nimbusds.jose.util.ResourceRetriever countingRetriever = + url -> { + fetchCount.incrementAndGet(); + return new Resource(jwkSetJson, "application/json"); + }; + // cacheDurationSeconds=0 and refreshReprieveThresholdMs=0 → every getKeys() re-fetches + JWTIssuerConfig.JwkSetFetcher fetcher = + new JWTIssuerConfig.JwkSetFetcher("https://example.com/jwks", countingRetriever, 0L, 0L); + + fetcher.getKeys(); // First call — fetches + fetcher.getKeys(); // Second call — cache expired, fetches again + assertEquals(2, fetchCount.get()); + } } 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 deleted file mode 100644 index 3406e439dbb1..000000000000 --- a/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTVerificationkeyResolverTest.java +++ /dev/null @@ -1,156 +0,0 @@ -/* - * 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.jwt; - -import static java.util.Arrays.asList; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.when; - -import java.util.Arrays; -import java.util.Iterator; -import java.util.List; -import org.apache.solr.SolrTestCaseJ4; -import org.apache.solr.security.jwt.JWTIssuerConfig.HttpsJwksFactory; -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.ArgumentMatchers; -import org.mockito.Mock; -import org.mockito.junit.MockitoJUnit; -import org.mockito.junit.MockitoRule; - -/** Tests the multi jwks resolver that can fetch keys from multiple JWKs */ -@SuppressWarnings("ArraysAsListWithZeroOrOneArgument") -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; - - @Override - @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 = 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 = refreshSequenceForSecondJwk.next(); - return keysToReturnFromSecondJwk; - }); - when(httpsJwksFactory.createList(ArgumentMatchers.anyList())) - .thenReturn(asList(firstJwkList, secondJwkList)); - - JWTIssuerConfig issuerConfig = - new JWTIssuerConfig("primary").setIss("foo").setJwksUrl(asList("url1", "url2")); - JWTIssuerConfig.setHttpsJwksFactory(httpsJwksFactory); - resolver = new JWTVerificationkeyResolver(Arrays.asList(issuerConfig), true); - - 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); - } - - @SuppressWarnings("NewClassNamingConvention") - public static 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; - } - } -} diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/jwt-authentication-plugin.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/jwt-authentication-plugin.adoc index 9c8551033f1b..70ba25593528 100644 --- a/solr/solr-ref-guide/modules/deployment-guide/pages/jwt-authentication-plugin.adoc +++ b/solr/solr-ref-guide/modules/deployment-guide/pages/jwt-authentication-plugin.adoc @@ -42,9 +42,7 @@ The simplest possible `security.json` for registering the plugin without configu } ---- -The plugin will by default require a valid JWT token for all traffic. - -If the `blockUnknown` property is set to `false` as in the above example, it is possible to start configuring the plugin using unauthenticated REST API calls, which is further described in section <>. +By default (`blockUnknown=false`), requests without a JWT are allowed through, which makes it possible to configure the plugin using unauthenticated REST API calls as described in <>. Set `blockUnknown=true` to require a valid JWT for all requests. == Configuration Parameters @@ -52,17 +50,17 @@ If the `blockUnknown` property is set to `false` as in the above example, it is [%header,format=csv,separator=;,cols="25%,50%,25%"] |=== Key ; Description ; Default -blockUnknown ; Set to `false` to if you need to perform configuration through REST API or if you use an Authorization Plugin and only want certain paths protected. By default all requests will require a token ; `true` +blockUnknown ; Controls whether requests without a JWT are blocked (`true`) or passed through (`false`). Set to `true` to enforce authentication on all requests. Set to `false` when you need to configure the plugin via unauthenticated REST API calls, or when an Authorization Plugin is used for path-level access control. ; `false` 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` ; requireIss ; Fails requests that lacks an `iss` (issuer) claim ; `true` -requireExp ; Fails requests that lacks an `exp` (expiry time) claim ; `true` +requireExp ; Fails requests that lacks an `exp` (expiry time) claim. A clock skew tolerance of 30 seconds is applied, so tokens expired within the last 30 seconds are still accepted. ; `true` algAllowlist ; JSON array with algorithms to accept: `HS256`, `HS384`, `HS512`, `RS256`, `RS384`, `RS512`, `ES256`, `ES384`, `ES512`, `PS256`, `PS384`, `PS512`, `none ; Default is to allow all algorithms jwkCacheDur ; Duration of JWK cache in seconds ; `3600` (1 hour) principalClaim ; What claim id to pull principal from ; `sub` rolesClaim ; What claim id to pull user roles from. Both top-level claim and nested claim is supported. Use `someClaim.child` syntax to address a claim `child` nested within the `someClaim` object. The claim must then either contain a space separated list of roles or a JSON array. The roles can then be used to define fine-grained access in an Authorization plugin ; By default the scopes from `scope` claim are passed on as user roles claimsMatch ; JSON object of claims (key) that must match a regular expression (value). Example: `{ "foo" : "A|B" }` will require the `foo` claim to be either "A" or "B". ; -adminUiScope ; Define what scope is requested when logging in from Admin UI ; If not defined, the first scope from `scope` parameter is used +adminUiScope ; Define what scope is requested when logging in from Admin UI ; If not defined, falls back to the first entry in `scope` if configured, otherwise to the hardcoded value `solr` redirectUris ; Valid location(s) for redirect after external authentication. Takes a string or array of strings. Must be the base URL of Solr, e.g., https://solr1.example.com:8983/solr/ and must match the list of redirect URIs registered with the Identity Provider beforehand. ; Defaults to empty list, i.e., any node is assumed to be a valid redirect target. trustedCerts ; One or more X.509 SSL certificates in plaintext PEM or PKCS#7 formats, that should be trusted when talking to IdPs. Newlines must be replaced with `\n`. See paragraph <> for more about its usage. ; Defaults to Java truststore trustedCertsFile ; Path to a file of type PEM, DER or PKCS#7, containing one or more X.509 SSL certificates that should be trusted when talking to IdPs. Can also be an array of file paths. See paragraph <> for more about its usage. ; Defaults to Java truststore @@ -84,7 +82,7 @@ clientId ; Client identifier for use with OpenID Connect. Required t jwksUrl ; 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 `jwksUrl` you may provide a static JSON object containing the public key(s) of the issuer. The format is either JWK or JWK Set, see https://tools.ietf.org/html/rfc7517#appendix-A[RFC7517] for examples. ; iss ; Unique issuer id as configured on the IdP. Incoming tokens must have a matching `iss` claim. Also used to resolve issuer when multiple issuers configured. ; Auto configured if `wellKnownUrl` is provided -aud ; Validates that the `aud` (audience) claim equals this string ; Uses `clientId` if configured +aud ; Validates that the `aud` (audience) claim equals this string ; If not set, audience validation is skipped entirely authorizationEndpoint; The URL for the Id Provider's authorization endpoint ; Auto configured if `wellKnownUrl` is provided tokenEndpoint; The URL for the Id Provider's token endpoint ; Auto configured if `wellKnownUrl` is provided authorizationFlow; Specifies the OAuth 2.0 flow to be used. Supported flows are 'implicit' and 'code_pkce' (for authorization code with 'Proof Key for Code Exchange'). Note: 'implicit' is deprecated and it is highly recommended to use 'code_pkce' instead. ; implicit