SOLR-13734 JWTAuthPlugin to support multiple issuers (#860)

This commit is contained in:
Jan Høydahl 2019-09-19 09:50:20 +02:00 committed by GitHub
parent 9e449ad0bc
commit dd729549b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 1126 additions and 539 deletions

View File

@ -95,6 +95,9 @@ Upgrade Notes
is now deprecated, and users are encouraged to use SolrTestCaseJ4.initAndGetDataDir() in it's place.
See SOLR-13664 for more details.
* For JWTAuthPlugin, the 'jwkUrl' configuration key is deprecated and may be removed later, please use 'jwksUrl'
instead. See SOLR-13734.
New Features
----------------------
@ -130,6 +133,9 @@ New Features
* SOLR-13713: JWTAuthPlugin to support multiple JWKS endpoints (janhoy)
* SOLR-13734: JWTAuthPlugin now supports multiple IdP issuers through configuring a new 'issuers' configuration key.
Access tokens issued and signed by any of the configured issuers will be validated (janhoy)
Improvements
----------------------

View File

@ -22,13 +22,8 @@ import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.invoke.MethodHandles;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.time.Instant;
@ -39,6 +34,8 @@ import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.StringTokenizer;
import java.util.regex.Pattern;
@ -61,16 +58,12 @@ import org.apache.solr.security.JWTAuthPlugin.JWTAuthenticationResponse.AuthCode
import org.eclipse.jetty.client.api.Request;
import org.jose4j.jwa.AlgorithmConstraints;
import org.jose4j.jwk.HttpsJwks;
import org.jose4j.jwk.JsonWebKey;
import org.jose4j.jwk.JsonWebKeySet;
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.keys.resolvers.JwksVerificationKeyResolver;
import org.jose4j.keys.resolvers.VerificationKeyResolver;
import org.jose4j.lang.JoseException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -81,11 +74,8 @@ import org.slf4j.LoggerFactory;
public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider, ConfigEditablePlugin {
private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
private static final String PARAM_BLOCK_UNKNOWN = "blockUnknown";
private static final String PARAM_JWK_URL = "jwkUrl";
private static final String PARAM_JWK = "jwk";
private static final String PARAM_ISSUER = "iss";
private static final String PARAM_AUDIENCE = "aud";
private static final String PARAM_REQUIRE_SUBJECT = "requireSub";
private static final String PARAM_REQUIRE_ISSUER = "requireIss";
private static final String PARAM_PRINCIPAL_CLAIM = "principalClaim";
private static final String PARAM_REQUIRE_EXPIRATIONTIME = "requireExp";
private static final String PARAM_ALG_WHITELIST = "algWhitelist";
@ -94,41 +84,39 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider,
private static final String PARAM_SCOPE = "scope";
private static final String PARAM_ADMINUI_SCOPE = "adminUiScope";
private static final String PARAM_REDIRECT_URIS = "redirectUris";
private static final String PARAM_CLIENT_ID = "clientId";
private static final String PARAM_WELL_KNOWN_URL = "wellKnownUrl";
private static final String PARAM_AUTHORIZATION_ENDPOINT = "authorizationEndpoint";
private static final String PARAM_ISSUERS = "issuers";
private static final String PARAM_REALM = "realm";
private static final String AUTH_REALM = "solr-jwt";
private static final String DEFAULT_AUTH_REALM = "solr-jwt";
private static final String CLAIM_SCOPE = "scope";
private static final long RETRY_INIT_DELAY_SECONDS = 30;
private static final long DEFAULT_REFRESH_REPRIEVE_THRESHOLD = 5000;
static final String PRIMARY_ISSUER = "PRIMARY";
private static final Set<String> PROPS = ImmutableSet.of(PARAM_BLOCK_UNKNOWN, PARAM_JWK_URL, PARAM_JWK, PARAM_ISSUER,
PARAM_AUDIENCE, PARAM_REQUIRE_SUBJECT, PARAM_PRINCIPAL_CLAIM, PARAM_REQUIRE_EXPIRATIONTIME, PARAM_ALG_WHITELIST,
PARAM_JWK_CACHE_DURATION, PARAM_CLAIMS_MATCH, PARAM_SCOPE, PARAM_CLIENT_ID, PARAM_WELL_KNOWN_URL,
PARAM_AUTHORIZATION_ENDPOINT, PARAM_ADMINUI_SCOPE, PARAM_REDIRECT_URIS);
private static final Set<String> PROPS = ImmutableSet.of(PARAM_BLOCK_UNKNOWN,
PARAM_REQUIRE_SUBJECT, PARAM_PRINCIPAL_CLAIM, PARAM_REQUIRE_EXPIRATIONTIME, PARAM_ALG_WHITELIST,
PARAM_JWK_CACHE_DURATION, PARAM_CLAIMS_MATCH, PARAM_SCOPE, PARAM_REALM,
PARAM_ADMINUI_SCOPE, PARAM_REDIRECT_URIS, PARAM_REQUIRE_ISSUER, PARAM_ISSUERS,
// These keys are supported for now to enable PRIMARY issuer config through top-level keys
JWTIssuerConfig.PARAM_JWK_URL, JWTIssuerConfig.PARAM_JWKS_URL, JWTIssuerConfig.PARAM_JWK, JWTIssuerConfig.PARAM_ISSUER,
JWTIssuerConfig.PARAM_CLIENT_ID, JWTIssuerConfig.PARAM_WELL_KNOWN_URL, JWTIssuerConfig.PARAM_AUDIENCE,
JWTIssuerConfig.PARAM_AUTHORIZATION_ENDPOINT);
private JwtConsumer jwtConsumer;
private String iss;
private String aud;
private boolean requireSubject;
private boolean requireExpirationTime;
private List<String> algWhitelist;
VerificationKeyResolver verificationKeyResolver;
private String principalClaim;
private HashMap<String, Pattern> claimsMatchCompiled;
private boolean blockUnknown;
private List<String> requiredScopes = new ArrayList<>();
private String clientId;
private WellKnownDiscoveryConfig oidcDiscoveryConfig;
private String confIdpConfigUrl;
private Map<String, Object> pluginConfig;
private Instant lastInitTime = Instant.now();
private String authorizationEndpoint;
private String adminUiScope;
private List<String> redirectUris;
private IssuerConfig issuerConfig;
private List<JWTIssuerConfig> issuerConfigs;
private boolean requireIssuer;
private JWTVerificationkeyResolver verificationKeyResolver;
String realm;
/**
* Initialize plugin
@ -138,6 +126,8 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider,
@SuppressWarnings("unchecked")
@Override
public void init(Map<String, Object> pluginConfig) {
this.pluginConfig = pluginConfig;
this.issuerConfigs = null;
List<String> unknownKeys = pluginConfig.keySet().stream().filter(k -> !PROPS.contains(k)).collect(Collectors.toList());
unknownKeys.remove("class");
unknownKeys.remove("");
@ -146,68 +136,15 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider,
}
blockUnknown = Boolean.parseBoolean(String.valueOf(pluginConfig.getOrDefault(PARAM_BLOCK_UNKNOWN, false)));
clientId = (String) pluginConfig.get(PARAM_CLIENT_ID);
requireSubject = Boolean.parseBoolean(String.valueOf(pluginConfig.getOrDefault(PARAM_REQUIRE_SUBJECT, "true")));
requireIssuer = Boolean.parseBoolean(String.valueOf(pluginConfig.getOrDefault(PARAM_REQUIRE_ISSUER, "true")));
requireExpirationTime = Boolean.parseBoolean(String.valueOf(pluginConfig.getOrDefault(PARAM_REQUIRE_EXPIRATIONTIME, "true")));
if (pluginConfig.get(PARAM_REQUIRE_SUBJECT) != null) {
log.warn("Parameter {} is no longer used and may generate error in a later version. A subject claim is now always required",
PARAM_REQUIRE_SUBJECT);
}
principalClaim = (String) pluginConfig.getOrDefault(PARAM_PRINCIPAL_CLAIM, "sub");
confIdpConfigUrl = (String) pluginConfig.get(PARAM_WELL_KNOWN_URL);
Object redirectUrisObj = pluginConfig.get(PARAM_REDIRECT_URIS);
redirectUris = Collections.emptyList();
if (redirectUrisObj != null) {
if (redirectUrisObj instanceof String) {
redirectUris = Collections.singletonList((String) redirectUrisObj);
} else if (redirectUrisObj instanceof List) {
redirectUris = (List<String>) redirectUrisObj;
}
}
if (confIdpConfigUrl != null) {
log.debug("Initializing well-known oidc config from {}", confIdpConfigUrl);
oidcDiscoveryConfig = WellKnownDiscoveryConfig.parse(confIdpConfigUrl);
iss = oidcDiscoveryConfig.getIssuer();
authorizationEndpoint = oidcDiscoveryConfig.getAuthorizationEndpoint();
}
if (pluginConfig.containsKey(PARAM_ISSUER)) {
if (iss != null) {
log.debug("Explicitly setting required issuer instead of using issuer from well-known config");
}
iss = (String) pluginConfig.get(PARAM_ISSUER);
}
if (pluginConfig.containsKey(PARAM_AUTHORIZATION_ENDPOINT)) {
if (authorizationEndpoint != null) {
log.debug("Explicitly setting authorizationEndpoint instead of using issuer from well-known config");
}
authorizationEndpoint = (String) pluginConfig.get(PARAM_AUTHORIZATION_ENDPOINT);
}
if (pluginConfig.containsKey(PARAM_AUDIENCE)) {
if (clientId != null) {
log.debug("Explicitly setting required audience instead of using configured clientId");
}
aud = (String) pluginConfig.get(PARAM_AUDIENCE);
} else {
aud = clientId;
}
algWhitelist = (List<String>) pluginConfig.get(PARAM_ALG_WHITELIST);
String requiredScopesStr = (String) pluginConfig.get(PARAM_SCOPE);
if (!StringUtils.isEmpty(requiredScopesStr)) {
requiredScopes = Arrays.asList(requiredScopesStr.split("\\s+"));
}
adminUiScope = (String) pluginConfig.get(PARAM_ADMINUI_SCOPE);
if (adminUiScope == null && requiredScopes.size() > 0) {
adminUiScope = requiredScopes.get(0);
log.warn("No adminUiScope given, using first scope in 'scope' list as required scope for accessing Admin UI");
}
if (adminUiScope == null) {
adminUiScope = "solr";
log.warn("Warning: No adminUiScope provided, fallback to 'solr' as required scope. If this is not correct, the Admin UI login may not work");
}
realm = (String) pluginConfig.getOrDefault(PARAM_REALM, DEFAULT_AUTH_REALM);
Map<String, String> claimsMatch = (Map<String, String>) pluginConfig.get(PARAM_CLAIMS_MATCH);
claimsMatchCompiled = new HashMap<>();
@ -217,70 +154,118 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider,
}
}
initJwk(pluginConfig);
String requiredScopesStr = (String) pluginConfig.get(PARAM_SCOPE);
if (!StringUtils.isEmpty(requiredScopesStr)) {
requiredScopes = Arrays.asList(requiredScopesStr.split("\\s+"));
}
long jwkCacheDuration = Long.parseLong((String) pluginConfig.getOrDefault(PARAM_JWK_CACHE_DURATION, "3600"));
JWTIssuerConfig.setHttpsJwksFactory(new JWTIssuerConfig.HttpsJwksFactory(jwkCacheDuration, DEFAULT_REFRESH_REPRIEVE_THRESHOLD));
issuerConfigs = new ArrayList<>();
// Try to parse an issuer from top level config, and add first (primary issuer)
Optional<JWTIssuerConfig> topLevelIssuer = parseIssuerFromTopLevelConfig(pluginConfig);
topLevelIssuer.ifPresent(ic -> {
issuerConfigs.add(ic);
log.warn("JWTAuthPlugin issuer is configured using top-level configuration keys. Please consider using the 'issuers' array instead.");
});
// Add issuers from 'issuers' key
issuerConfigs.addAll(parseIssuers(pluginConfig));
verificationKeyResolver = new JWTVerificationkeyResolver(issuerConfigs, requireIssuer);
if (issuerConfigs.size() > 0 && getPrimaryIssuer().getAuthorizationEndpoint() != null) {
adminUiScope = (String) pluginConfig.get(PARAM_ADMINUI_SCOPE);
if (adminUiScope == null && requiredScopes.size() > 0) {
adminUiScope = requiredScopes.get(0);
log.warn("No adminUiScope given, using first scope in 'scope' list as required scope for accessing Admin UI");
}
if (adminUiScope == null) {
adminUiScope = "solr";
log.info("No adminUiScope provided, fallback to 'solr' as required scope for Admin UI login may not work");
}
Object redirectUrisObj = pluginConfig.get(PARAM_REDIRECT_URIS);
redirectUris = Collections.emptyList();
if (redirectUrisObj != null) {
if (redirectUrisObj instanceof String) {
redirectUris = Collections.singletonList((String) redirectUrisObj);
} else if (redirectUrisObj instanceof List) {
redirectUris = (List<String>) redirectUrisObj;
}
}
}
initConsumer();
lastInitTime = Instant.now();
}
@SuppressWarnings("unchecked")
private void initJwk(Map<String, Object> pluginConfig) {
this.pluginConfig = pluginConfig;
Object confJwkUrl = pluginConfig.get(PARAM_JWK_URL);
Map<String, Object> confJwk = (Map<String, Object>) pluginConfig.get(PARAM_JWK);
long jwkCacheDuration = Long.parseLong((String) pluginConfig.getOrDefault(PARAM_JWK_CACHE_DURATION, "3600"));
jwtConsumer = null;
int jwkConfigured = confIdpConfigUrl != null ? 1 : 0;
jwkConfigured += confJwkUrl != null ? 1 : 0;
jwkConfigured += confJwk != null ? 1 : 0;
if (jwkConfigured > 1) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "JWTAuthPlugin needs to configure exactly one of " +
PARAM_WELL_KNOWN_URL + ", " + PARAM_JWK_URL + " and " + PARAM_JWK);
}
if (jwkConfigured == 0) {
log.warn("Initialized JWTAuthPlugin without any JWK config. Requests with jwk header will fail.");
}
HttpsJwksFactory httpsJwksFactory = new HttpsJwksFactory(jwkCacheDuration, DEFAULT_REFRESH_REPRIEVE_THRESHOLD);
if (confJwkUrl != null) {
private Optional<JWTIssuerConfig> parseIssuerFromTopLevelConfig(Map<String, Object> conf) {
try {
List<String> urls = (confJwkUrl instanceof List) ? (List<String>)confJwkUrl : Collections.singletonList((String) confJwkUrl);
issuerConfig = new IssuerConfig(iss, urls);
issuerConfig.setHttpsJwksFactory(httpsJwksFactory);
verificationKeyResolver = new JWTVerificationkeyResolver(issuerConfig);
} catch (ClassCastException e) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Parameter " + PARAM_JWK_URL + " must be either List or String");
if (conf.get(JWTIssuerConfig.PARAM_JWK_URL) != null) {
log.warn("Configuration uses deprecated key {}. Please use {} instead", JWTIssuerConfig.PARAM_JWK_URL, JWTIssuerConfig.PARAM_JWKS_URL);
}
} else if (confJwk != null) {
try {
JsonWebKeySet jwks = parseJwkSet(confJwk);
issuerConfig = new IssuerConfig(iss, jwks);
verificationKeyResolver = new JwksVerificationKeyResolver(jwks.getJsonWebKeys());
} catch (JoseException e) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Invalid JWTAuthPlugin configuration, " + PARAM_JWK + " parse error", e);
}
} else if (oidcDiscoveryConfig != null) {
List<String> urls = Collections.singletonList(oidcDiscoveryConfig.getJwksUrl());
issuerConfig = new IssuerConfig(iss, urls);
issuerConfig.setHttpsJwksFactory(httpsJwksFactory);
verificationKeyResolver = new JWTVerificationkeyResolver(issuerConfig);
}
initConsumer();
log.debug("JWK configured");
}
@SuppressWarnings("unchecked")
JsonWebKeySet parseJwkSet(Map<String, Object> jwkObj) throws JoseException {
JsonWebKeySet webKeySet = new JsonWebKeySet();
if (jwkObj.containsKey("keys")) {
List<Object> jwkList = (List<Object>) jwkObj.get("keys");
for (Object jwkO : jwkList) {
webKeySet.addJsonWebKey(JsonWebKey.Factory.newJwk((Map<String, Object>) jwkO));
JWTIssuerConfig primary = new JWTIssuerConfig(PRIMARY_ISSUER)
.setIss((String) conf.get(JWTIssuerConfig.PARAM_ISSUER))
.setAud((String) conf.get(JWTIssuerConfig.PARAM_AUDIENCE))
.setJwksUrl(conf.get(JWTIssuerConfig.PARAM_JWKS_URL) != null ? conf.get(JWTIssuerConfig.PARAM_JWKS_URL) : conf.get(JWTIssuerConfig.PARAM_JWK_URL))
.setAuthorizationEndpoint((String) conf.get(JWTIssuerConfig.PARAM_AUTHORIZATION_ENDPOINT))
.setClientId((String) conf.get(JWTIssuerConfig.PARAM_CLIENT_ID))
.setWellKnownUrl((String) conf.get(JWTIssuerConfig.PARAM_WELL_KNOWN_URL));
if (conf.get(JWTIssuerConfig.PARAM_JWK) != null) {
primary.setJsonWebKeySet(JWTIssuerConfig.parseJwkSet((Map<String, Object>) conf.get(JWTIssuerConfig.PARAM_JWK)));
}
if (primary.isValid()) {
log.debug("Found issuer in top level config");
primary.init();
return Optional.of(primary);
} else {
webKeySet = new JsonWebKeySet(JsonWebKey.Factory.newJwk(jwkObj));
log.debug("No issuer configured in top level config");
return Optional.empty();
}
} catch (JoseException je) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Failed parsing issuer from top level config", je);
}
}
/**
* Fetch the primary issuer to be used for Admin UI authentication. Callers of this method must ensure that at least
* one issuer is configured. The primary issuer is defined as the first issuer configured in the list.
* @return JWTIssuerConfig object for the primary issuer
*/
JWTIssuerConfig getPrimaryIssuer() {
if (issuerConfigs.size() == 0) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "No issuers configured");
}
return issuerConfigs.get(0);
}
/**
* Initialize optional additional issuers configured in 'issuers' config map
* @param pluginConfig the main config object
* @return a list of parsed {@link JWTIssuerConfig} objects
*/
@SuppressWarnings("unchecked")
List<JWTIssuerConfig> parseIssuers(Map<String, Object> pluginConfig) {
List<JWTIssuerConfig> configs = new ArrayList<>();
try {
List<Map<String, Object>> issuers = (List<Map<String, Object>>) pluginConfig.get(PARAM_ISSUERS);
if (issuers != null) {
issuers.forEach(issuerConf -> {
JWTIssuerConfig ic = new JWTIssuerConfig(issuerConf);
ic.init();
configs.add(ic);
log.debug("Found issuer with name {} and issuerId {}", ic.getName(), ic.getIss());
});
}
return configs;
} catch(ClassCastException cce) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Parameter " + PARAM_ISSUERS + " has wrong format.", cce);
}
return webKeySet;
}
/**
@ -313,15 +298,22 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider,
}
JWTAuthenticationResponse authResponse = authenticate(header);
if (AuthCode.SIGNATURE_INVALID.equals(authResponse.getAuthCode()) && issuerConfig.usesHttpsJwk()) {
log.warn("Signature validation failed. Refreshing JWKs from IdP before trying again: {}",
authResponse.getJwtException() == null ? "" : authResponse.getJwtException().getMessage());
for (HttpsJwks httpsJwks : issuerConfig.getHttpsJwks()) {
String exceptionMessage = authResponse.getJwtException() != null ? authResponse.getJwtException().getMessage() : "";
if (AuthCode.SIGNATURE_INVALID.equals(authResponse.getAuthCode())) {
String issuer = jwtConsumer.processToClaims(header).getIssuer();
if (issuer != null) {
Optional<JWTIssuerConfig> issuerConfig = issuerConfigs.stream().filter(ic -> issuer.equals(ic.getIss())).findFirst();
if (issuerConfig.isPresent() && issuerConfig.get().usesHttpsJwk()) {
log.info("Signature validation failed for issuer {}. Refreshing JWKs from IdP before trying again: {}",
issuer, exceptionMessage);
for (HttpsJwks httpsJwks : issuerConfig.get().getHttpsJwks()) {
httpsJwks.refresh();
}
authResponse = authenticate(header);
authResponse = authenticate(header); // Retry
exceptionMessage = authResponse.getJwtException() != null ? authResponse.getJwtException().getMessage() : "";
}
}
}
String exceptionMessage = authResponse.getJwtException() != null ? authResponse.getJwtException().getMessage() : "";
switch (authResponse.getAuthCode()) {
case AUTHENTICATED:
@ -392,12 +384,9 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider,
*/
protected JWTAuthenticationResponse authenticate(String authorizationHeader) {
if (authorizationHeader != null) {
StringTokenizer st = new StringTokenizer(authorizationHeader);
if (st.hasMoreTokens()) {
String bearer = st.nextToken();
if (bearer.equalsIgnoreCase("Bearer") && st.hasMoreTokens()) {
String jwtCompact = parseAuthorizationHeader(authorizationHeader);
if (jwtCompact != null) {
try {
String jwtCompact = st.nextToken();
try {
JwtClaims jwtClaims = jwtConsumer.processToClaims(jwtCompact);
String principal = jwtClaims.getStringClaimValue(principalClaim);
@ -461,9 +450,6 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider,
} else {
return new JWTAuthenticationResponse(AuthCode.AUTZ_HEADER_PROBLEM, "Authorization header is not in correct format");
}
} else {
return new JWTAuthenticationResponse(AuthCode.AUTZ_HEADER_PROBLEM, "Authorization header is not in correct format");
}
} else {
// No Authorization header
if (blockUnknown) {
@ -475,17 +461,30 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider,
}
}
private String parseAuthorizationHeader(String authorizationHeader) {
StringTokenizer st = new StringTokenizer(authorizationHeader);
if (st.hasMoreTokens()) {
String bearer = st.nextToken();
if (bearer.equalsIgnoreCase("Bearer") && st.hasMoreTokens()) {
return st.nextToken();
}
}
return null;
}
private void initConsumer() {
JwtConsumerBuilder jwtConsumerBuilder = new JwtConsumerBuilder()
.setAllowedClockSkewInSeconds(30); // allow some leeway in validating time based claims to account for clock skew
if (iss != null)
jwtConsumerBuilder.setExpectedIssuer(iss); // whom the JWT needs to have been issued by
if (aud != null) {
jwtConsumerBuilder.setExpectedAudience(aud); // to whom the JWT is intended for
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
}
String[] audiences = 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 (requireSubject)
jwtConsumerBuilder.setRequireSubject();
if (requireExpirationTime)
jwtConsumerBuilder.setRequireExpirationTime();
@ -538,7 +537,7 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider,
private void authenticationFailure(HttpServletResponse response, String message, int httpCode, BearerWwwAuthErrorCode responseError) throws IOException {
List<String> wwwAuthParams = new ArrayList<>();
wwwAuthParams.add("Bearer realm=\"" + AUTH_REALM + "\"");
wwwAuthParams.add("Bearer realm=\"" + realm + "\"");
if (responseError != null) {
wwwAuthParams.add("error=\"" + responseError + "\"");
wwwAuthParams.add("error_description=\"" + message + "\"");
@ -550,9 +549,10 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider,
}
protected String generateAuthDataHeader() {
JWTIssuerConfig primaryIssuer = getPrimaryIssuer();
Map<String,Object> data = new HashMap<>();
data.put(PARAM_AUTHORIZATION_ENDPOINT, authorizationEndpoint);
data.put("client_id", clientId);
data.put(JWTIssuerConfig.PARAM_AUTHORIZATION_ENDPOINT, primaryIssuer.getAuthorizationEndpoint());
data.put("client_id", primaryIssuer.getClientId());
data.put("scope", adminUiScope);
data.put("redirect_uris", redirectUris);
String headerJson = Utils.toJSONString(data);
@ -636,171 +636,6 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider,
}
}
/**
* Config object for a OpenId Connect well-known config
* Typically exposed through /.well-known/openid-configuration endpoint
*/
public static class WellKnownDiscoveryConfig {
private static Map<String, Object> securityConf;
WellKnownDiscoveryConfig(Map<String, Object> securityConf) {
WellKnownDiscoveryConfig.securityConf = securityConf;
}
public static WellKnownDiscoveryConfig parse(String urlString) {
try {
URL url = new URL(urlString);
if (!Arrays.asList("https", "file").contains(url.getProtocol())) {
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Well-known config URL must be HTTPS or file");
}
return parse(url.openStream());
} catch (MalformedURLException e) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Well-known config URL " + urlString + " is malformed", e);
} catch (IOException e) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Well-known config could not be read from url " + urlString, e);
}
}
public static WellKnownDiscoveryConfig parse(String json, Charset charset) {
return parse(new ByteArrayInputStream(json.getBytes(charset)));
}
@SuppressWarnings("unchecked")
public static WellKnownDiscoveryConfig parse(InputStream configStream) {
securityConf = (Map<String, Object>) Utils.fromJSON(configStream);
return new WellKnownDiscoveryConfig(securityConf);
}
public String getJwksUrl() {
return (String) securityConf.get("jwks_uri");
}
public String getIssuer() {
return (String) securityConf.get("issuer");
}
public String getAuthorizationEndpoint() {
return (String) securityConf.get("authorization_endpoint");
}
public String getUserInfoEndpoint() {
return (String) securityConf.get("userinfo_endpoint");
}
public String getTokenEndpoint() {
return (String) securityConf.get("token_endpoint");
}
@SuppressWarnings("unchecked")
public List<String> getScopesSupported() {
return (List<String>) securityConf.get("scopes_supported");
}
@SuppressWarnings("unchecked")
public List<String> getResponseTypesSupported() {
return (List<String>) securityConf.get("response_types_supported");
}
}
/**
* Holds information about an IdP (issuer), such as issuer ID, JWK url(s), keys etc
*/
public static class IssuerConfig {
private HttpsJwksFactory httpsJwksFactory;
private String iss;
private JsonWebKeySet jsonWebKeySet;
private List<String> jwksUrl;
private List<HttpsJwks> httpsJwks;
/**
* Create config
* @param iss unique issuer id string
* @param jwksUrls list of URLs for JWKs endpoints
*/
public IssuerConfig(String iss, List<String> jwksUrls) {
this.jwksUrl = jwksUrls;
this.iss = iss;
}
public IssuerConfig(String iss, JsonWebKeySet jsonWebKeySet) {
this.iss = iss;
this.jsonWebKeySet = jsonWebKeySet;
}
public String getIss() {
return iss;
}
public List<String> getJwksUrl() {
return jwksUrl;
}
public List<HttpsJwks> getHttpsJwks() {
if (httpsJwks == null) {
if (httpsJwksFactory == null) {
httpsJwksFactory = new HttpsJwksFactory(3600, DEFAULT_REFRESH_REPRIEVE_THRESHOLD);
log.warn("Created HttpsJwksFactory with default cache duration and reprieveThreshold");
}
httpsJwks = httpsJwksFactory.createList(getJwksUrl());
}
return httpsJwks;
}
public void setHttpsJwks(List<HttpsJwks> httpsJwks) {
this.httpsJwks = httpsJwks;
}
/**
* Set the factory to use when creating HttpsJwks objects
* @param httpsJwksFactory factory with custom settings
*/
public void setHttpsJwksFactory(HttpsJwksFactory httpsJwksFactory) {
this.httpsJwksFactory = httpsJwksFactory;
}
public JsonWebKeySet getJsonWebKeySet() {
return jsonWebKeySet;
}
/**
* Check if the issuer is backed by HttpsJwk url(s)
* @return true if keys are fetched over https
*/
public boolean usesHttpsJwk() {
return getJwksUrl() != null && !getJwksUrl().isEmpty();
}
}
public static class HttpsJwksFactory {
private final long jwkCacheDuration;
private final long refreshReprieveThreshold;
public HttpsJwksFactory(long jwkCacheDuration, long refreshReprieveThreshold) {
this.jwkCacheDuration = jwkCacheDuration;
this.refreshReprieveThreshold = refreshReprieveThreshold;
}
public HttpsJwks create(String url) {
try {
URL jwkUrl = new URL(url);
if (!"https".equalsIgnoreCase(jwkUrl.getProtocol())) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, PARAM_JWK_URL + " must use HTTPS");
}
} catch (MalformedURLException e) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Url " + url + " configured in " + PARAM_JWK_URL + " is not a valid URL");
}
HttpsJwks httpsJkws = new HttpsJwks(url);
httpsJkws.setDefaultCacheDuration(jwkCacheDuration);
httpsJkws.setRefreshReprieveThreshold(refreshReprieveThreshold);
return httpsJkws;
}
public List<HttpsJwks> createList(List<String> jwkUrls) {
return jwkUrls.stream().map(this::create).collect(Collectors.toList());
}
}
@Override
protected boolean interceptInternodeRequest(HttpRequest httpRequest, HttpContext httpContext) {
if (httpContext instanceof HttpClientContext) {
@ -824,4 +659,17 @@ public class JWTAuthPlugin extends AuthenticationPlugin implements SpecProvider,
}
return false;
}
public List<JWTIssuerConfig> getIssuerConfigs() {
return issuerConfigs;
}
/**
* Lookup issuer config by its name
* @param name name property of config
* @return issuer config object or null if not found
*/
public JWTIssuerConfig getIssuerConfigByName(String name) {
return issuerConfigs.stream().filter(ic -> name.equals(ic.getName())).findAny().orElse(null);
}
}

View File

@ -0,0 +1,438 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.solr.security;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.invoke.MethodHandles;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.apache.solr.common.SolrException;
import org.apache.solr.common.util.Utils;
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;
/**
* Holds information about an IdP (issuer), such as issuer ID, JWK url(s), keys etc
*/
public class JWTIssuerConfig {
private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
static final String PARAM_ISS_NAME = "name";
@Deprecated(since = "8.3") // Remove this option at some point
static final String PARAM_JWK_URL = "jwkUrl";
static final String PARAM_JWKS_URL = "jwksUrl";
static final String PARAM_JWK = "jwk";
static final String PARAM_ISSUER = "iss";
static final String PARAM_AUDIENCE = "aud";
static final String PARAM_WELL_KNOWN_URL = "wellKnownUrl";
static final String PARAM_AUTHORIZATION_ENDPOINT = "authorizationEndpoint";
static final String PARAM_CLIENT_ID = "clientId";
private static HttpsJwksFactory httpsJwksFactory =
new HttpsJwksFactory(3600, 5000);
private String iss;
private String aud;
private JsonWebKeySet jsonWebKeySet;
private String name;
private List<String> jwksUrl;
private List<HttpsJwks> httpsJwks;
private String wellKnownUrl;
private WellKnownDiscoveryConfig wellKnownDiscoveryConfig;
private String clientId;
private String authorizationEndpoint;
/**
* Create config for further configuration with setters, builder style.
* Once all values are set, call {@link #init()} before further use
*
* @param name a unique name for this issuer
*/
public JWTIssuerConfig(String name) {
this.name = name;
}
/**
* Initialize issuer config from a generic configuration map
*
* @param configMap map of configuration keys anv values
*/
public JWTIssuerConfig(Map<String, Object> configMap) {
parseConfigMap(configMap);
}
/**
* Call this to validate and initialize an object which is populated with setters.
* Init will fetch wellKnownUrl if relevant
* @throws SolrException if issuer is missing
*/
public void init() {
if (!isValid()) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Configuration is not valid");
}
if (wellKnownUrl != null) {
wellKnownDiscoveryConfig = fetchWellKnown(wellKnownUrl);
if (iss == null) {
iss = wellKnownDiscoveryConfig.getIssuer();
}
if (jwksUrl == null) {
jwksUrl = Collections.singletonList(wellKnownDiscoveryConfig.getJwksUrl());
}
if (authorizationEndpoint == null) {
authorizationEndpoint = wellKnownDiscoveryConfig.getAuthorizationEndpoint();
}
}
if (iss == null && usesHttpsJwk() && !JWTAuthPlugin.PRIMARY_ISSUER.equals(name)) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Missing required config 'iss' for issuer " + getName());
}
}
/**
* Parses configuration for one IssuerConfig and sets all variables found
* @throws SolrException if unknown parameter names found in config
*/
protected void parseConfigMap(Map<String, Object> configMap) {
HashMap<String, Object> conf = new HashMap<>(configMap); // Clone
setName((String) conf.get(PARAM_ISS_NAME));
setWellKnownUrl((String) conf.get(PARAM_WELL_KNOWN_URL));
setIss((String) conf.get(PARAM_ISSUER));
setClientId((String) conf.get(PARAM_CLIENT_ID));
setAud((String) conf.get(PARAM_AUDIENCE));
if (conf.get(PARAM_JWK_URL) != null) {
log.warn("Configuration uses deprecated key {}. Please use {} instead", PARAM_JWK_URL, PARAM_JWKS_URL);
}
Object confJwksUrl = conf.get(PARAM_JWKS_URL) != null ? conf.get(PARAM_JWKS_URL) : conf.get(PARAM_JWK_URL);
setJwksUrl(confJwksUrl);
setJsonWebKeySet(conf.get(PARAM_JWK));
setAuthorizationEndpoint((String) conf.get(PARAM_AUTHORIZATION_ENDPOINT));
conf.remove(PARAM_WELL_KNOWN_URL);
conf.remove(PARAM_ISSUER);
conf.remove(PARAM_ISS_NAME);
conf.remove(PARAM_CLIENT_ID);
conf.remove(PARAM_AUDIENCE);
conf.remove(PARAM_JWKS_URL);
conf.remove(PARAM_JWK_URL);
conf.remove(PARAM_JWK);
conf.remove(PARAM_AUTHORIZATION_ENDPOINT);
if (!conf.isEmpty()) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Unknown configuration key " + conf.keySet() + " for issuer " + name);
}
}
/**
* Setter that takes a jwk config object, parses it into a {@link JsonWebKeySet} and sets it
* @param jwksObject the config object to parse
*/
@SuppressWarnings("unchecked")
protected void setJsonWebKeySet(Object jwksObject) {
try {
if (jwksObject != null) {
jsonWebKeySet = parseJwkSet((Map<String, Object>) jwksObject);
}
} catch (JoseException e) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Failed parsing parameter 'jwk' for issuer " + getName(), e);
}
}
@SuppressWarnings("unchecked")
protected static JsonWebKeySet parseJwkSet(Map<String, Object> jwkObj) throws JoseException {
JsonWebKeySet webKeySet = new JsonWebKeySet();
if (jwkObj.containsKey("keys")) {
List<Object> jwkList = (List<Object>) jwkObj.get("keys");
for (Object jwkO : jwkList) {
webKeySet.addJsonWebKey(JsonWebKey.Factory.newJwk((Map<String, Object>) jwkO));
}
} else {
webKeySet = new JsonWebKeySet(JsonWebKey.Factory.newJwk(jwkObj));
}
return webKeySet;
}
private WellKnownDiscoveryConfig fetchWellKnown(String wellKnownUrl) {
return WellKnownDiscoveryConfig.parse(wellKnownUrl);
}
public String getIss() {
return iss;
}
public JWTIssuerConfig setIss(String iss) {
this.iss = iss;
return this;
}
public String getName() {
return name;
}
public JWTIssuerConfig setName(String name) {
this.name = name;
return this;
}
public String getWellKnownUrl() {
return wellKnownUrl;
}
public JWTIssuerConfig setWellKnownUrl(String wellKnownUrl) {
this.wellKnownUrl = wellKnownUrl;
return this;
}
public List<String> getJwksUrls() {
return jwksUrl;
}
public JWTIssuerConfig setJwksUrl(List<String> jwksUrl) {
this.jwksUrl = jwksUrl;
return this;
}
/**
* Setter that converts from String or List into a list
* @param jwksUrlListOrString object that should be either string or list
* @return this for builder pattern
* @throws SolrException if wrong type
*/
@SuppressWarnings("unchecked")
public JWTIssuerConfig setJwksUrl(Object jwksUrlListOrString) {
if (jwksUrlListOrString instanceof String)
this.jwksUrl = Collections.singletonList((String) jwksUrlListOrString);
else if (jwksUrlListOrString instanceof List)
this.jwksUrl = (List<String>) jwksUrlListOrString;
else if (jwksUrlListOrString != null)
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Parameter " + PARAM_JWKS_URL + " must be either List or String");
return this;
}
public List<HttpsJwks> getHttpsJwks() {
if (httpsJwks == null) {
httpsJwks = httpsJwksFactory.createList(getJwksUrls());
}
return httpsJwks;
}
/**
* Set the factory to use when creating HttpsJwks objects
* @param httpsJwksFactory factory with custom settings
*/
public static void setHttpsJwksFactory(HttpsJwksFactory httpsJwksFactory) {
JWTIssuerConfig.httpsJwksFactory = httpsJwksFactory;
}
public JsonWebKeySet getJsonWebKeySet() {
return jsonWebKeySet;
}
public JWTIssuerConfig setJsonWebKeySet(JsonWebKeySet jsonWebKeySet) {
this.jsonWebKeySet = jsonWebKeySet;
return this;
}
/**
* Check if the issuer is backed by HttpsJwk url(s)
* @return true if keys are fetched over https
*/
public boolean usesHttpsJwk() {
return getJwksUrls() != null && !getJwksUrls().isEmpty();
}
public WellKnownDiscoveryConfig getWellKnownDiscoveryConfig() {
return wellKnownDiscoveryConfig;
}
public String getAud() {
return aud;
}
public JWTIssuerConfig setAud(String aud) {
this.aud = aud;
return this;
}
public String getClientId() {
return clientId;
}
public JWTIssuerConfig setClientId(String clientId) {
this.clientId = clientId;
return this;
}
public String getAuthorizationEndpoint() {
return authorizationEndpoint;
}
public JWTIssuerConfig setAuthorizationEndpoint(String authorizationEndpoint) {
this.authorizationEndpoint = authorizationEndpoint;
return this;
}
public Map<String,Object> asConfig() {
HashMap<String,Object> config = new HashMap<>();
putIfNotNull(config, PARAM_ISS_NAME, name);
putIfNotNull(config, PARAM_ISSUER, iss);
putIfNotNull(config, PARAM_AUDIENCE, aud);
putIfNotNull(config, PARAM_JWKS_URL, jwksUrl);
putIfNotNull(config, PARAM_WELL_KNOWN_URL, wellKnownUrl);
putIfNotNull(config, PARAM_CLIENT_ID, clientId);
putIfNotNull(config, PARAM_AUTHORIZATION_ENDPOINT, authorizationEndpoint);
if (jsonWebKeySet != null) {
putIfNotNull(config, PARAM_JWK, jsonWebKeySet.getJsonWebKeys());
}
return config;
}
private void putIfNotNull(HashMap<String, Object> config, String paramName, Object value) {
if (value != null) {
config.put(paramName, value);
}
}
/**
* Validates that this config has a name and either jwksUrl, wellkKownUrl or jwk
* @return true if a configuration is found and is valid, otherwise false
* @throws SolrException if configuration is present but wrong
*/
public boolean isValid() {
int jwkConfigured = wellKnownUrl != null ? 1 : 0;
jwkConfigured += jwksUrl != null ? 2 : 0;
jwkConfigured += jsonWebKeySet != null ? 2 : 0;
if (jwkConfigured > 3) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "JWTAuthPlugin needs to configure exactly one of " +
PARAM_WELL_KNOWN_URL + ", " + PARAM_JWKS_URL + " and " + PARAM_JWK);
}
if (jwkConfigured > 0 && name == null) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR,
"Parameter 'name' is required for issuer configurations");
}
return jwkConfigured > 0;
}
/**
*
*/
static class HttpsJwksFactory {
private final long jwkCacheDuration;
private final long refreshReprieveThreshold;
public HttpsJwksFactory(long jwkCacheDuration, long refreshReprieveThreshold) {
this.jwkCacheDuration = jwkCacheDuration;
this.refreshReprieveThreshold = refreshReprieveThreshold;
}
private HttpsJwks create(String url) {
try {
URL jwksUrl = new URL(url);
if (!"https".equalsIgnoreCase(jwksUrl.getProtocol())) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, PARAM_JWKS_URL + " must use HTTPS");
}
} catch (MalformedURLException e) {
throw new SolrException(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);
return httpsJkws;
}
public List<HttpsJwks> createList(List<String> jwkUrls) {
return jwkUrls.stream().map(this::create).collect(Collectors.toList());
}
}
/**
* Config object for a OpenId Connect well-known config
* Typically exposed through /.well-known/openid-configuration endpoint
*/
public static class WellKnownDiscoveryConfig {
private Map<String, Object> securityConf;
WellKnownDiscoveryConfig(Map<String, Object> securityConf) {
this.securityConf = securityConf;
}
public static WellKnownDiscoveryConfig parse(String urlString) {
try {
URL url = new URL(urlString);
if (!Arrays.asList("https", "file").contains(url.getProtocol())) {
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Well-known config URL must be HTTPS or file");
}
return parse(url.openStream());
} catch (MalformedURLException e) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Well-known config URL " + urlString + " is malformed", e);
} catch (IOException e) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Well-known config could not be read from url " + urlString, e);
}
}
public static WellKnownDiscoveryConfig parse(String json, Charset charset) {
return parse(new ByteArrayInputStream(json.getBytes(charset)));
}
@SuppressWarnings("unchecked")
public static WellKnownDiscoveryConfig parse(InputStream configStream) {
return new WellKnownDiscoveryConfig((Map<String, Object>) Utils.fromJSON(configStream));
}
public String getJwksUrl() {
return (String) securityConf.get("jwks_uri");
}
public String getIssuer() {
return (String) securityConf.get("issuer");
}
public String getAuthorizationEndpoint() {
return (String) securityConf.get("authorization_endpoint");
}
public String getUserInfoEndpoint() {
return (String) securityConf.get("userinfo_endpoint");
}
public String getTokenEndpoint() {
return (String) securityConf.get("token_endpoint");
}
@SuppressWarnings("unchecked")
public List<String> getScopesSupported() {
return (List<String>) securityConf.get("scopes_supported");
}
@SuppressWarnings("unchecked")
public List<String> getResponseTypesSupported() {
return (List<String>) securityConf.get("response_types_supported");
}
}
}

View File

@ -20,13 +20,21 @@ 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.Map;
import java.util.Set;
import org.apache.solr.security.JWTAuthPlugin.IssuerConfig;
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;
@ -35,34 +43,34 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Adaption of {@link org.jose4j.keys.resolvers.HttpsJwksVerificationKeyResolver} to resolve
* keys from multiple HttpsJwks endpoints, which is sometimes necessary if the IdP
* does not publish all public keys that may have signed a token through the main JWKs endpoint.
* Such setups typically have support for multiple signing backends, each serving its own JWKs
* endpoint for its keys.
* 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 collects all keys from all endpoints into a single list and
* the rest of the implementation is equivalent to that of HttpsJwksVerificationKeyResolver.
* 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.
*
* No attempt is made to keep track of which key came from which JWKs endpoint, and if a
* key is not found in any cache, all JWKs endpoints are refreshed before a single retry.
*
* NOTE: This class can subclass HttpsJwksVerificationKeyResolver once a new version of jose4j is available
* 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 VerificationJwkSelector verificationJwkSelector = new VerificationJwkSelector();
private IssuerConfig issuerConfig;
private Map<String, JWTIssuerConfig> issuerConfigs = new HashMap<>();
private final boolean requireIssuer;
/**
* Resolves key from a list of JWKs URLs stored in IssuerConfig
* @param issuerConfig Configuration object for the 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 JWTVerificationkeyResolver(IssuerConfig issuerConfig) {
this.issuerConfig = issuerConfig;
assert(issuerConfig.usesHttpsJwk());
public JWTVerificationkeyResolver(Collection<JWTIssuerConfig> issuerConfigs, boolean requireIssuer) {
this.requireIssuer = requireIssuer;
issuerConfigs.forEach(ic -> {
this.issuerConfigs.put(ic.getIss(), ic);
});
}
@Override
@ -70,15 +78,47 @@ public class JWTVerificationkeyResolver implements VerificationKeyResolver {
JsonWebKey theChosenOne;
List<JsonWebKey> 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 verifiction 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 verifiction failed due to no configured issuer with id " + tokenIssuer);
}
}
}
// Add all keys into a master list
if (issuerConfig.usesHttpsJwk()) {
keysSource = "[" + String.join(", ", issuerConfig.getJwksUrls()) + "]";
for (HttpsJwks hjwks : issuerConfig.getHttpsJwks()) {
jsonWebKeys.addAll(hjwks.getJsonWebKeys());
}
} else {
keysSource = "static list of keys in security.json";
jsonWebKeys.addAll(issuerConfig.getJsonWebKeySet().getJsonWebKeys());
}
theChosenOne = verificationJwkSelector.select(jws, jsonWebKeys);
if (theChosenOne == null) {
if (theChosenOne == null && issuerConfig.usesHttpsJwk()) {
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);
@ -89,25 +129,25 @@ public class JWTVerificationkeyResolver implements VerificationKeyResolver {
}
theChosenOne = verificationJwkSelector.select(jws, jsonWebKeys);
}
} catch (JoseException | IOException e) {
} catch (JoseException | IOException | InvalidJwtException | MalformedClaimException e) {
StringBuilder sb = new StringBuilder();
sb.append("Unable to find a suitable verification key for JWS w/ header ").append(jws.getHeaders().getFullHeaderAsJsonString());
sb.append(" due to an unexpected exception (").append(e).append(") while obtaining or using keys from JWKS endpoints at ");
sb.append(issuerConfig.getJwksUrl());
sb.append(" due to an unexpected exception (").append(e).append(") while obtaining or using keys from source ");
sb.append(keysSource);
throw new UnresolvableKeyException(sb.toString(), e);
}
if (theChosenOne == null) {
StringBuilder sb = new StringBuilder();
sb.append("Unable to find a suitable verification key for JWS w/ header ").append(jws.getHeaders().getFullHeaderAsJsonString());
sb.append(" from JWKs ").append(jsonWebKeys).append(" obtained from ").append(issuerConfig.getJwksUrl());
sb.append(" from ").append(jsonWebKeys.size()).append(" keys from source ").append(keysSource);
throw new UnresolvableKeyException(sb.toString());
}
return theChosenOne.getKey();
}
IssuerConfig getIssuerConfig() {
return issuerConfig;
Set<JWTIssuerConfig> getIssuerConfigs() {
return new HashSet<>(issuerConfigs.values());
}
}

View File

@ -9,6 +9,10 @@
"kid": "test",
"alg": "RS256",
"n": "jeyrvOaZrmKWjyNXt0myAc_pJ1hNt3aRupExJEx1ewPaL9J9HFgSCjMrYxCB1ETO1NDyZ3nSgjZis-jHHDqBxBjRdq_t1E2rkGFaYbxAyKt220Pwgme_SFTB9MXVrFQGkKyjmQeVmOmV6zM3KK8uMdKQJ4aoKmwBcF5Zg7EZdDcKOFgpgva1Jq-FlEsaJ2xrYDYo3KnGcOHIt9_0NQeLsqZbeWYLxYni7uROFncXYV5FhSJCeR4A_rrbwlaCydGxE0ToC_9HNYibUHlkJjqyUhAgORCbNS8JLCJH8NUi5sDdIawK9GTSyvsJXZ-QHqo4cMUuxWV5AJtaRGghuMUfqQ"
}
},
"realm": "my-solr-jwt",
"adminUiScope": "solr:admin",
"authorizationEndpoint": "http://acmepaymentscorp/oauth/auz/authorize",
"clientId": "solr-cluster"
}
}

View File

@ -1,6 +1,6 @@
{
"authentication" : {
"class": "solr.JWTAuthPlugin",
"jwkUrl": "https://127.0.0.1:8999/this-will-fail.wks"
"jwksUrl": "https://127.0.0.1:8999/this-will-fail.wks"
}
}

View File

@ -24,6 +24,7 @@ import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@ -40,6 +41,7 @@ import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.solr.SolrTestCaseJ4;
import org.apache.solr.client.solrj.impl.HttpClientUtil;
import org.apache.solr.cloud.SolrCloudAuthTestCase;
import org.apache.solr.common.util.Base64;
import org.apache.solr.common.util.Pair;
import org.apache.solr.common.util.TimeSource;
import org.apache.solr.common.util.Utils;
@ -130,6 +132,20 @@ public class JWTAuthPluginIntegrationTest extends SolrCloudAuthTestCase {
get(baseUrl + "/admin/info/system", null);
}
@Test
public void infoRequestValidateXSolrAuthHeaders() throws IOException {
Map<String, String> headers = getHeaders(baseUrl + "/admin/info/system", null);
assertEquals("401", headers.get("code"));
assertEquals("HTTP/1.1 401 Require authentication", headers.get(null));
assertEquals("Bearer realm=\"my-solr-jwt\"", headers.get("WWW-Authenticate"));
String authData = new String(Base64.base64ToByteArray(headers.get("X-Solr-AuthData")), UTF_8);
assertEquals("{\n" +
" \"scope\":\"solr:admin\",\n" +
" \"redirect_uris\":[],\n" +
" \"authorizationEndpoint\":\"http://acmepaymentscorp/oauth/auz/authorize\",\n" +
" \"client_id\":\"solr-cluster\"}", authData);
}
@Test
public void testMetrics() throws Exception {
boolean isUseV2Api = random().nextBoolean();
@ -215,6 +231,20 @@ public class JWTAuthPluginIntegrationTest extends SolrCloudAuthTestCase {
return new Pair<>(result, code);
}
private Map<String,String> getHeaders(String url, String token) throws IOException {
URL createUrl = new URL(url);
HttpURLConnection conn = (HttpURLConnection) createUrl.openConnection();
if (token != null)
conn.setRequestProperty("Authorization", "Bearer " + token);
conn.connect();
int code = conn.getResponseCode();
Map<String, String> result = new HashMap<>();
conn.getHeaderFields().forEach((k,v) -> result.put(k, v.get(0)));
result.put("code", String.valueOf(code));
conn.disconnect();
return result;
}
private Pair<String, Integer> post(String url, String json, String token) throws IOException {
URL createUrl = new URL(url);
HttpURLConnection con = (HttpURLConnection) createUrl.openConnection();

View File

@ -16,20 +16,18 @@
*/
package org.apache.solr.security;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.Principal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.solr.SolrTestCaseJ4;
import org.apache.solr.common.SolrException;
import org.apache.solr.common.util.Base64;
@ -40,6 +38,7 @@ 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;
@ -54,17 +53,33 @@ public class JWTAuthPluginTest extends SolrTestCaseJ4 {
private static String testHeader;
private static String slimHeader;
private JWTAuthPlugin plugin;
private HashMap<String, Object> testJwk;
private static RsaJsonWebKey rsaJsonWebKey;
private HashMap<String, Object> testConfig;
private HashMap<String, Object> minimalConfig;
@BeforeClass
public static void beforeAll() throws Exception {
// Shared with other tests
static HashMap<String, Object> testJwk;
static {
// Generate an RSA key pair, which will be used for signing and verification of the JWT, wrapped in a JWK
try {
rsaJsonWebKey = RsaJwkGenerator.generateJwk(2048);
rsaJsonWebKey.setKeyId("k1");
testJwk = new HashMap<>();
testJwk.put("kty", rsaJsonWebKey.getKeyType());
testJwk.put("e", BigEndianBigInteger.toBase64Url(rsaJsonWebKey.getRsaPublicKey().getPublicExponent()));
testJwk.put("use", rsaJsonWebKey.getUse());
testJwk.put("kid", rsaJsonWebKey.getKeyId());
testJwk.put("alg", rsaJsonWebKey.getAlgorithm());
testJwk.put("n", BigEndianBigInteger.toBase64Url(rsaJsonWebKey.getRsaPublicKey().getModulus()));
} catch (JoseException e) {
fail("Failed static initialization: " + e.getMessage());
}
}
@BeforeClass
public static void beforeAll() throws Exception {
JwtClaims claims = generateClaims();
JsonWebSignature jws = new JsonWebSignature();
jws.setPayload(claims.toJson());
@ -110,16 +125,6 @@ public class JWTAuthPluginTest extends SolrTestCaseJ4 {
// Create an auth plugin
plugin = new JWTAuthPlugin();
// Create a JWK config for security.json
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()));
testConfig = new HashMap<>();
testConfig.put("class", "org.apache.solr.security.JWTAuthPlugin");
testConfig.put("jwk", testJwk);
@ -176,34 +181,35 @@ public class JWTAuthPluginTest extends SolrTestCaseJ4 {
}
@Test
public void initWithJwkUrl() {
@Deprecated(since = "8.3")
public void initWithJwkUrlForBackwardsCompat() {
HashMap<String, Object> authConf = new HashMap<>();
authConf.put("jwkUrl", "https://127.0.0.1:9999/foo.jwk");
plugin = new JWTAuthPlugin();
plugin.init(authConf);
JWTVerificationkeyResolver resolver = (JWTVerificationkeyResolver) plugin.verificationKeyResolver;
assertEquals(1, resolver.getIssuerConfig().getJwksUrl().size());
assertEquals(1, plugin.getIssuerConfigs().size());
assertEquals(1, plugin.getIssuerConfigs().get(0).getJwksUrls().size());
}
@Test
public void initWithJwksUrl() {
HashMap<String, Object> authConf = new HashMap<>();
authConf.put("jwksUrl", "https://127.0.0.1:9999/foo.jwk");
plugin = new JWTAuthPlugin();
plugin.init(authConf);
assertEquals(1, plugin.getIssuerConfigs().size());
assertEquals(1, plugin.getIssuerConfigs().get(0).getJwksUrls().size());
}
@Test
public void initWithJwkUrlArray() {
HashMap<String, Object> authConf = new HashMap<>();
authConf.put("jwkUrl", Arrays.asList("https://127.0.0.1:9999/foo.jwk", "https://127.0.0.1:9999/foo2.jwk"));
authConf.put("jwksUrl", Arrays.asList("https://127.0.0.1:9999/foo.jwk", "https://127.0.0.1:9999/foo2.jwk"));
authConf.put("iss", "myIssuer");
plugin = new JWTAuthPlugin();
plugin.init(authConf);
JWTVerificationkeyResolver resolver = (JWTVerificationkeyResolver) plugin.verificationKeyResolver;
assertEquals(2, resolver.getIssuerConfig().getJwksUrl().size());
}
@Test
public void parseJwkSet() throws Exception {
plugin.parseJwkSet(testJwk);
HashMap<String, Object> testJwks = new HashMap<>();
List<Map<String, Object>> keys = new ArrayList<>();
keys.add(testJwk);
testJwks.put("keys", keys);
plugin.parseJwkSet(testJwks);
assertEquals(1, plugin.getIssuerConfigs().size());
assertEquals(2, plugin.getIssuerConfigs().get(0).getJwksUrls().size());
}
@Test
@ -283,20 +289,21 @@ public class JWTAuthPluginTest extends SolrTestCaseJ4 {
@Test
public void missingIssAudExp() {
testConfig.put("requireIss", "false");
testConfig.put("requireExp", "false");
testConfig.put("requireSub", "false");
plugin.init(testConfig);
JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(slimHeader);
assertTrue(resp.isAuthenticated());
assertTrue(resp.getErrorMessage(), resp.isAuthenticated());
// Missing exp header
// Missing exp claim
testConfig.put("requireExp", true);
plugin.init(testConfig);
resp = plugin.authenticate(slimHeader);
assertEquals(JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.JWT_VALIDATION_EXCEPTION, resp.getAuthCode());
testConfig.put("requireExp", false);
// Missing sub header
testConfig.put("requireSub", true);
// Missing issuer claim
testConfig.put("requireIss", true);
plugin.init(testConfig);
resp = plugin.authenticate(slimHeader);
assertEquals(JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.JWT_VALIDATION_EXCEPTION, resp.getAuthCode());
@ -316,7 +323,7 @@ public class JWTAuthPluginTest extends SolrTestCaseJ4 {
testConfig.put("scope", "solr:read solr:admin");
plugin.init(testConfig);
JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(testHeader);
assertTrue(resp.isAuthenticated());
assertTrue(resp.getErrorMessage(), resp.isAuthenticated());
Principal principal = resp.getPrincipal();
assertTrue(principal instanceof VerifiedUserRoles);
@ -353,14 +360,14 @@ public class JWTAuthPluginTest extends SolrTestCaseJ4 {
@Test
public void minimalConfigPassThrough() {
testConfig.put("blockUnknown", false);
minimalConfig.put("blockUnknown", false);
plugin.init(minimalConfig);
JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(null);
assertEquals(JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.PASS_THROUGH, resp.getAuthCode());
}
@Test
public void wellKnownConfig() {
public void wellKnownConfigNoHeaderPassThrough() {
String wellKnownUrl = TEST_PATH().resolve("security").resolve("jwt_well-known-config.json").toAbsolutePath().toUri().toString();
testConfig.put("wellKnownUrl", wellKnownUrl);
testConfig.remove("jwk");
@ -369,41 +376,29 @@ public class JWTAuthPluginTest extends SolrTestCaseJ4 {
assertEquals(JWTAuthPlugin.JWTAuthenticationResponse.AuthCode.PASS_THROUGH, resp.getAuthCode());
}
@Test(expected = SolrException.class)
public void onlyOneJwkConfig() {
testConfig.put("jwkUrl", "http://127.0.0.1:45678/myJwk");
plugin.init(testConfig);
}
@Test(expected = SolrException.class)
public void wellKnownConfigNotHttps() {
testConfig.put("wellKnownUrl", "http://127.0.0.1:45678/.well-known/config");
plugin.init(testConfig);
}
@Test(expected = SolrException.class)
public void wellKnownConfigNotReachable() {
testConfig.put("wellKnownUrl", "https://127.0.0.1:45678/.well-known/config");
@Test
public void defaultRealm() {
String wellKnownUrl = TEST_PATH().resolve("security").resolve("jwt_well-known-config.json").toAbsolutePath().toUri().toString();
testConfig.put("wellKnownUrl", wellKnownUrl);
testConfig.remove("jwk");
plugin.init(testConfig);
assertEquals("solr-jwt", plugin.realm);
}
@Test
public void wellKnownConfigFromInputstream() throws IOException {
Path configJson = TEST_PATH().resolve("security").resolve("jwt_well-known-config.json");
JWTAuthPlugin.WellKnownDiscoveryConfig config = JWTAuthPlugin.WellKnownDiscoveryConfig.parse(Files.newInputStream(configJson));
assertEquals("https://acmepaymentscorp/oauth/jwks", config.getJwksUrl());
public void configureRealm() {
String wellKnownUrl = TEST_PATH().resolve("security").resolve("jwt_well-known-config.json").toAbsolutePath().toUri().toString();
testConfig.put("wellKnownUrl", wellKnownUrl);
testConfig.remove("jwk");
testConfig.put("realm", "myRealm");
plugin.init(testConfig);
assertEquals("myRealm", plugin.realm);
}
@Test
public void wellKnownConfigFromString() throws IOException {
Path configJson = TEST_PATH().resolve("security").resolve("jwt_well-known-config.json");
String configString = StringUtils.join(Files.readAllLines(configJson), "\n");
JWTAuthPlugin.WellKnownDiscoveryConfig config = JWTAuthPlugin.WellKnownDiscoveryConfig.parse(configString, StandardCharsets.UTF_8);
assertEquals("https://acmepaymentscorp/oauth/jwks", config.getJwksUrl());
assertEquals("http://acmepaymentscorp", config.getIssuer());
assertEquals("http://acmepaymentscorp/oauth/auz/authorize", config.getAuthorizationEndpoint());
assertEquals(Arrays.asList("READ", "WRITE", "DELETE", "openid", "scope", "profile", "email", "address", "phone"), config.getScopesSupported());
assertEquals(Arrays.asList("code", "code id_token", "code token", "code id_token token", "token", "id_token", "id_token token"), config.getResponseTypesSupported());
@Test(expected = SolrException.class)
public void bothJwksUrlAndJwkFails() {
testConfig.put("jwksUrl", "http://127.0.0.1:45678/myJwk");
plugin.init(testConfig);
}
@Test
@ -419,4 +414,44 @@ public class JWTAuthPluginTest extends SolrTestCaseJ4 {
assertEquals("http://acmepaymentscorp/oauth/auz/authorize", parsed.get("authorizationEndpoint"));
assertEquals("solr-cluster", parsed.get("client_id"));
}
@Test
public void initWithTwoIssuers() {
HashMap<String, Object> authConf = new HashMap<>();
JWTIssuerConfig iss1 = new JWTIssuerConfig("iss1").setIss("1").setAud("aud1")
.setJwksUrl("https://127.0.0.1:9999/foo.jwk");
JWTIssuerConfig iss2 = new JWTIssuerConfig("iss2").setIss("2").setAud("aud2")
.setJwksUrl(Arrays.asList("https://127.0.0.1:9999/foo.jwk", "https://127.0.0.1:9999/foo2.jwk"));
authConf.put("issuers", Arrays.asList(iss1.asConfig(), iss2.asConfig()));
plugin = new JWTAuthPlugin();
plugin.init(authConf);
assertEquals(2, plugin.getIssuerConfigs().size());
assertTrue(plugin.getIssuerConfigs().get(0).usesHttpsJwk());
assertTrue(plugin.getIssuerConfigs().get(1).usesHttpsJwk());
JWTIssuerConfig issuer1 = plugin.getIssuerConfigByName("iss1");
JWTIssuerConfig issuer2 = plugin.getIssuerConfigByName("iss2");
assertNotNull(issuer1);
assertNotNull(issuer2);
assertEquals(2, issuer2.getJwksUrls().size());
assertEquals("iss1", plugin.getPrimaryIssuer().getName());
assertEquals("aud1", issuer1.getAud());
}
@Test
public void initWithToplevelAndIssuersCombined() {
HashMap<String, Object> authConf = new HashMap<>();
JWTIssuerConfig iss1 = new JWTIssuerConfig("iss1").setIss("1").setAud("aud1")
.setJwksUrl("https://127.0.0.1:9999/foo.jwk");
authConf.put("issuers", Collections.singletonList(iss1.asConfig()));
authConf.put("aud", "aud2");
authConf.put("jwksUrl", Arrays.asList("https://127.0.0.1:9999/foo.jwk", "https://127.0.0.1:9999/foo2.jwk"));
plugin = new JWTAuthPlugin();
plugin.init(authConf);
assertEquals(2, plugin.getIssuerConfigs().size());
assertEquals("PRIMARY", plugin.getPrimaryIssuer().getName());
assertEquals("aud2", plugin.getPrimaryIssuer().getAud());
// Top-level (name=PRIMARY) issuer config does not need "iss" for back compat
assertNull(plugin.getPrimaryIssuer().getIss());
}
}

View File

@ -0,0 +1,156 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.solr.security;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.apache.solr.common.SolrException;
import org.jose4j.jwk.JsonWebKeySet;
import org.junit.Before;
import org.junit.Test;
import org.noggit.JSONUtil;
import static org.apache.solr.SolrTestCaseJ4.TEST_PATH;
import static org.apache.solr.security.JWTAuthPluginTest.testJwk;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class JWTIssuerConfigTest {
private JWTIssuerConfig testIssuer;
private Map<String, Object> testIssuerConfigMap;
private String testIssuerJson;
@Before
public void setUp() throws Exception {
testIssuer = new JWTIssuerConfig("name")
.setJwksUrl("https://issuer/path")
.setIss("issuer")
.setAud("audience")
.setClientId("clientid")
.setWellKnownUrl("wellknown")
.setAuthorizationEndpoint("https://issuer/authz");
testIssuerConfigMap = testIssuer.asConfig();
testIssuerJson = "{\n" +
" \"aud\":\"audience\",\n" +
" \"wellKnownUrl\":\"wellknown\",\n" +
" \"clientId\":\"clientid\",\n" +
" \"jwksUrl\":[\"https://issuer/path\"],\n" +
" \"name\":\"name\",\n" +
" \"iss\":\"issuer\",\n" +
" \"authorizationEndpoint\":\"https://issuer/authz\"}";
}
@Test
public void parseConfigMap() {
// Do a round-trip from map -> object -> map -> json
JWTIssuerConfig issuerConfig = new JWTIssuerConfig(testIssuerConfigMap);
issuerConfig.isValid();
assertEquals(testIssuerJson, JSONUtil.toJSON(issuerConfig.asConfig()));
}
@Test(expected = SolrException.class)
public void parseConfigMapNoName() {
testIssuerConfigMap.remove("name"); // Will fail validation
new JWTIssuerConfig(testIssuerConfigMap).isValid();
}
@Test
public void parseJwkSet() throws Exception {
HashMap<String, Object> testJwks = new HashMap<>();
List<Map<String, Object>> keys = new ArrayList<>();
keys.add(testJwk);
testJwks.put("keys", keys);
JWTIssuerConfig.parseJwkSet(testJwks);
}
@Test
public void setJwksUrl() {
JWTIssuerConfig conf = new JWTIssuerConfig("myConf");
conf.setJwksUrl("http://server/path");
}
@Test
public void asConfig() {
assertEquals(testIssuerJson, JSONUtil.toJSON(testIssuer.asConfig()));
}
@Test
public void isValid() {
assertTrue(testIssuer.isValid());
}
@Test(expected = SolrException.class)
public void notValidBothJwksAndJwk() {
testIssuer.setJsonWebKeySet(new JsonWebKeySet());
testIssuer.isValid();
}
@Test
public void parseIssuerConfigExplicit() {
HashMap<String, Object> issuerConfigMap = new HashMap<>();
issuerConfigMap.put("name", "myName");
issuerConfigMap.put("iss", "myIss");
issuerConfigMap.put("jwksUrl", "https://host/jwk");
JWTIssuerConfig issuerConfig = new JWTIssuerConfig(issuerConfigMap);
assertEquals("myIss", issuerConfig.getIss());
assertEquals("myName", issuerConfig.getName());
assertEquals(1, issuerConfig.getJwksUrls().size());
assertEquals("https://host/jwk", issuerConfig.getJwksUrls().get(0));
}
@Test
public void wellKnownConfigFromInputstream() throws IOException {
Path configJson = TEST_PATH().resolve("security").resolve("jwt_well-known-config.json");
JWTIssuerConfig.WellKnownDiscoveryConfig config = JWTIssuerConfig.WellKnownDiscoveryConfig.parse(Files.newInputStream(configJson));
assertEquals("https://acmepaymentscorp/oauth/jwks", config.getJwksUrl());
}
@Test
public void wellKnownConfigFromString() throws IOException {
Path configJson = TEST_PATH().resolve("security").resolve("jwt_well-known-config.json");
String configString = StringUtils.join(Files.readAllLines(configJson), "\n");
JWTIssuerConfig.WellKnownDiscoveryConfig config = JWTIssuerConfig.WellKnownDiscoveryConfig.parse(configString, StandardCharsets.UTF_8);
assertEquals("https://acmepaymentscorp/oauth/jwks", config.getJwksUrl());
assertEquals("http://acmepaymentscorp", config.getIssuer());
assertEquals("http://acmepaymentscorp/oauth/auz/authorize", config.getAuthorizationEndpoint());
assertEquals(Arrays.asList("READ", "WRITE", "DELETE", "openid", "scope", "profile", "email", "address", "phone"), config.getScopesSupported());
assertEquals(Arrays.asList("code", "code id_token", "code token", "code id_token token", "token", "id_token", "id_token token"), config.getResponseTypesSupported());
}
@Test(expected = SolrException.class)
public void wellKnownConfigNotHttps() {
JWTIssuerConfig.WellKnownDiscoveryConfig.parse("http://127.0.0.1:45678/.well-known/config");
}
@Test(expected = SolrException.class)
public void wellKnownConfigNotReachable() {
JWTIssuerConfig.WellKnownDiscoveryConfig.parse("https://127.0.0.1:45678/.well-known/config");
}
}

View File

@ -17,12 +17,12 @@
package org.apache.solr.security;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import org.apache.solr.SolrTestCaseJ4;
import org.apache.solr.security.JWTAuthPlugin.HttpsJwksFactory;
import org.apache.solr.security.JWTAuthPlugin.IssuerConfig;
import org.apache.solr.security.JWTIssuerConfig.HttpsJwksFactory;
import org.jose4j.jwk.HttpsJwks;
import org.jose4j.jwk.JsonWebKey;
import org.jose4j.jwk.RsaJsonWebKey;
@ -89,9 +89,9 @@ public class JWTVerificationkeyResolverTest extends SolrTestCaseJ4 {
});
when(httpsJwksFactory.createList(anyList())).thenReturn(asList(firstJwkList, secondJwkList));
IssuerConfig issuerConfig = new IssuerConfig("foo", asList("url1", "url2"));
JWTIssuerConfig issuerConfig = new JWTIssuerConfig("primary").setIss("foo").setJwksUrl(asList("url1", "url2"));
issuerConfig.setHttpsJwksFactory(httpsJwksFactory);
resolver = new JWTVerificationkeyResolver(issuerConfig);
resolver = new JWTVerificationkeyResolver(Arrays.asList(issuerConfig), true);
assumeWorkingMockito();
}

View File

@ -34,36 +34,49 @@ 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 REST API calls, which is further described below.
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 <<editing-jwt-authentication-plugin-configuration,Editing JWT Authentication Plugin Configuration>>.
== Configuration Parameters
//TODO: standard is not to put parameters in tables but use labeled lists instead
[%header,format=csv,separator=;]
[%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`
wellKnownUrl ; URL to an https://openid.net/specs/openid-connect-discovery-1_0.html[OpenID Connect Discovery] endpoint ; (no default)
clientId ; Client identifier for use with OpenID Connect ; (no default value) Required to authenticate with Admin UI
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` ;
jwkUrl ; 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 `jwkUrl` you may provide a JSON object here containing the public key(s) of the issuer. ;
iss ; Validates that the `iss` (issuer) claim equals this string ; Auto configured if `wellKnownUrl` is provided
aud ; Validates that the `aud` (audience) claim equals this string ; If `clientId` is configured, require `aud` to match it
requireSub ; Makes `sub` (subject) claim mandatory ; `true`
requireExp ; Makes `exp` (expiry time) claim mandatory ; `true`
requireIss ; Fails requests that lacks an `iss` (issuer) claim ; `true`
requireExp ; Fails requests that lacks an `exp` (expiry time) claim ; `true`
algWhitelist ; 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`
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". ; (none)
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
authorizationEndpoint; The URL for the Id Provider's authorization endpoint ; Auto configured if `wellKnownUrl` is provided
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.
issuers ; List of issuers (Identity providers) to support. See section <<issuer-configuration,Issuer configuration>> for configuration syntax ;
|===
=== Issuer configuration
This plugin supports one or more token issuers (IdPs). Issuers are configured as a list of JSON objects under the `issuers` configuration key. The first issuer in the list is the "Primary Issuer", which is the one used for logging in to the Admin UI.
[%header,format=csv,separator=;,cols="25%,50%,25%"]
|===
Key ; Description ; Default
name ; A unique name of the issuer. Used to manipulate list through API. ;
wellKnownUrl ; URL to an https://openid.net/specs/openid-connect-discovery-1_0.html[OpenID Connect Discovery] endpoint ;
clientId ; Client identifier for use with OpenID Connect. Required to authenticate with Admin UI. Needed for primary issuer only ;
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
authorizationEndpoint; The URL for the Id Provider's authorization endpoint ; Auto configured if `wellKnownUrl` is provided
|===
TIP: For backwards compatibility, all the configuration keys for the primary issuer may be configured as top-level keys, except `name`.
== More Configuration Examples
=== With JWK URL
=== With JWKS URL
To start enforcing authentication for all users, requiring a valid JWT in the `Authorization` header, you need to configure the plugin with one or more https://tools.ietf.org/html/rfc7517[JSON Web Key]s (JWK). This is a JSON document containing the key used to sign/encrypt the JWT. It could be a symmetric or asymmetric key. The JWK can either be fetched (and cached) from an external HTTPS endpoint or specified directly in `security.json`. Below is an example of the former:
[source,json]
@ -71,11 +84,13 @@ To start enforcing authentication for all users, requiring a valid JWT in the `A
{
"authentication": {
"class": "solr.JWTAuthPlugin",
"jwkUrl": "https://my.key.server/jwk.json"
"jwksUrl": "https://my.key.server/jwk.json"
}
}
----
TIP: The configuration key `jwkUrl` is also supported as an alternative to `jwksUrl` for backwards compatibility with early versions of the plugin.
=== With Admin UI Support
The next example shows configuring using https://openid.net/specs/openid-connect-discovery-1_0.html[OpenID Connect Discovery] with a well-known URI for automatic configuration of many common settings, including ability to use the Admin UI with an OpenID Connect enabled Identity Provider.
@ -91,10 +106,10 @@ The next example shows configuring using https://openid.net/specs/openid-connect
}
----
In this case, `jwkUrl`, `iss` and `authorizationEndpoint` will be automatically configured from the fetched configuration.
In this case, `jwksUrl`, `iss` and `authorizationEndpoint` will be automatically configured from the fetched configuration.
=== Complex Example
Let's look at a more complex configuration, this time with a static embedded JWK:
Let's look at a more complex configuration, this time with two issuers configured, where one uses a static embedded JWK:
[source,json]
----
@ -102,19 +117,29 @@ Let's look at a more complex configuration, this time with a static embedded JWK
"authentication": {
"class": "solr.JWTAuthPlugin", <1>
"blockUnknown": true, <2>
"jwk": { <3>
"principalClaim": "solruid", <3>
"claimsMatch": { "foo" : "A|B", "dept" : "IT" }, <4>
"scope": "solr:read solr:write solr:admin", <5>
"algWhitelist" : [ "RS256", "RS384", "RS512" ], <6>
"issuers": [ <7>
{
"name": "example1-static", <8>
"jwk": { <9>
"e": "AQAB",
"kid": "k1",
"kty": "RSA",
"n": "3ZF6wBGPMsLzsS1KLghxaVpZtXD3nTLzDm0c974i9-KNU_1rhhBeiVfS64VfEQmP8SA470jEy7yWcvnz9GvG-YAlm9iOwVF7jLl2awdws0ocFjdSPT3SjPQKzOeMO7G9XqNTkrvoFCn1YAi26fbhhcqkwZDoeTcHQdRN32frzccuPhZrwImApIedroKLlKWv2IvPDnz2Bpe2WWVc2HdoWYqEVD3p_BEy8f-RTSHK3_8kDDF9yAwI9jx7CK1_C-eYxXltm-6rpS5NGyFm0UNTZMxVU28Tl7LX8Vb6CikyCQ9YRCtk_CvpKWmEuKEp9I28KHQNmGkDYT90nt3vjbCXxw"
"n": "3ZF6w....vjbCXxw"
},
"clientId": "solr-client-12345", <4>
"iss": "https://example.com/idp", <5>
"aud": "https://example.com/solr", <6>
"principalClaim": "solruid", <7>
"claimsMatch": { "foo" : "A|B", "dept" : "IT" }, <8>
"scope": "solr:read solr:write solr:admin", <9>
"algWhitelist" : [ "RS256", "RS384", "RS512" ] <10>
"clientId": "solr-client-12345", <10>
"iss": "https://example.com/idp", <11>
"aud": "https://example.com/solr" <12>
},
{
"name": "example2",
"wellKnownUrl": "https://example2.com/.well-known/oidc", <13>
"aud": "https://example2.com/solr"
}
]
}
}
----
@ -123,19 +148,22 @@ Let's comment on this config:
<1> Plugin class
<2> Make sure to block anyone without a valid token (this is also the default)
<3> Here we pass the JWK inline instead of referring to a URL with `jwkUrl`
<4> Set the client id registered with Identity Provider
<5> The issuer claim must match "https://example.com/idp"
<6> The audience claim must match "https://example.com/solr"
<7> Fetch the user id from another claim than the default `sub`
<8> Require that the `roles` claim is one of "A" or "B" and that the `dept` claim is "IT"
<9> Require one of the scopes `solr:read`, `solr:write` or `solr:admin`
<10> Only accept RSA algorithms for signatures
<3> Fetch the user id from another claim than the default `sub`
<4> Require that the `roles` claim is one of "A" or "B" and that the `dept` claim is "IT"
<5> Require one of the scopes `solr:read`, `solr:write` or `solr:admin`
<6> Only accept RSA algorithms for signatures
<7> Array of issuer configurations
<8> Each issuer object should have a unique name
<9> Here we pass the JWK inline instead of referring to a URL with `jwksUrl`
<10> Set the client id registered with Identity Provider
<11> Configure the issuer id. Will be used for validating tokens. A token's 'iss' claim must match one of the configured issuer IDs.
<12> Configure the audience claim. A token's 'aud' claim must match 'aud' for one of the configured issuers.
<13> This issuer is auto configured through discovery, so 'iss' and JWK settings are not required
== Editing JWT Authentication Plugin Configuration
All properties mentioned above can be set or changed using the Config Edit API. You can thus start with a simple configuration with only `class` configured and then configure the rest using the API.
All properties mentioned above can be set or changed using the Config Edit API. You can thus start with a simple configuration with only `class` and `blockUnknown=false` configured and then configure the rest using the API.
=== Set a Configuration Property
@ -166,7 +194,9 @@ curl http://localhost:8983/api/cluster/security/authentication -H 'Content-type:
====
--
Insert a valid JWT access token in compact serialization format (`xxx.yyy.zzz` above) to authenticate with Solr once the plugin is active.
Insert a valid JWT access token in compact serialization format (`xxx.yyy.zzz` above) to authenticate with Solr once the plugin is active, or leave `blockUnknown=false` until configuration is complete and then switch it to `true` to start enforcing.
NOTE: There is currently no support for adding multiple token issuers though REST API, but you can configure one issuer through the API by using the 'issuer' properties as top-level properties.
== Using Clients with JWT Auth
@ -187,7 +217,7 @@ curl -H "Authorization: Bearer xxxxxx.xxxxxx.xxxxxx" http://localhost:8983/solr/
=== Admin UI
When this plugin is enabled, users will be redirected to a login page in the Admin UI once they attempt to do a restricted action. The page has a button that users will click and be redirected to the Identity Provider's login page. Once authenticated, the user will be redirected back to Solr Admin UI to the last known location. The session will last as long as the JWT token expiry time and is valid for one Solr server only. That means you have to login again when navigating to another Solr node. There is also a logout menu in the left column where user can explicitly log out.
When this plugin is enabled, users will be redirected to a login page in the Admin UI once they attempt to do a restricted action. The page has a button that users will click and be redirected to the Identity Provider's login page. If more than one issuer (IdP) is configured, the first in the list will be used for Admin UI. Once authenticated, the user will be redirected back to Solr Admin UI to the last known location. The session will last as long as the JWT token expiry time and is valid for one Solr server only. That means you have to login again when navigating to another Solr node. There is also a logout menu in the left column where user can explicitly log out.
== Using the Solr Control Script with JWT Auth