diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt index 8f1f90d4092..9b8e69c9e08 100644 --- a/solr/CHANGES.txt +++ b/solr/CHANGES.txt @@ -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 ---------------------- diff --git a/solr/core/src/java/org/apache/solr/security/JWTAuthPlugin.java b/solr/core/src/java/org/apache/solr/security/JWTAuthPlugin.java index 9d0d44aab48..e642751e2e5 100644 --- a/solr/core/src/java/org/apache/solr/security/JWTAuthPlugin.java +++ b/solr/core/src/java/org/apache/solr/security/JWTAuthPlugin.java @@ -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 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 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 algWhitelist; - VerificationKeyResolver verificationKeyResolver; private String principalClaim; private HashMap claimsMatchCompiled; private boolean blockUnknown; private List requiredScopes = new ArrayList<>(); - private String clientId; - private WellKnownDiscoveryConfig oidcDiscoveryConfig; - private String confIdpConfigUrl; private Map pluginConfig; private Instant lastInitTime = Instant.now(); - private String authorizationEndpoint; private String adminUiScope; private List redirectUris; - private IssuerConfig issuerConfig; - + private List 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 pluginConfig) { + this.pluginConfig = pluginConfig; + this.issuerConfigs = null; List unknownKeys = pluginConfig.keySet().stream().filter(k -> !PROPS.contains(k)).collect(Collectors.toList()); unknownKeys.remove("class"); unknownKeys.remove(""); @@ -146,69 +136,16 @@ 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) 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) pluginConfig.get(PARAM_ALG_WHITELIST); + realm = (String) pluginConfig.getOrDefault(PARAM_REALM, DEFAULT_AUTH_REALM); - 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"); - } - Map claimsMatch = (Map) pluginConfig.get(PARAM_CLAIMS_MATCH); claimsMatchCompiled = new HashMap<>(); if (claimsMatch != null) { @@ -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 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) redirectUrisObj; + } + } + } + + initConsumer(); lastInitTime = Instant.now(); } @SuppressWarnings("unchecked") - private void initJwk(Map pluginConfig) { - this.pluginConfig = pluginConfig; - Object confJwkUrl = pluginConfig.get(PARAM_JWK_URL); - Map confJwk = (Map) 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) { - try { - List urls = (confJwkUrl instanceof List) ? (List)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"); + private Optional parseIssuerFromTopLevelConfig(Map conf) { + try { + 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); + 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) conf.get(JWTIssuerConfig.PARAM_JWK))); } - } else if (oidcDiscoveryConfig != null) { - List urls = Collections.singletonList(oidcDiscoveryConfig.getJwksUrl()); - issuerConfig = new IssuerConfig(iss, urls); - issuerConfig.setHttpsJwksFactory(httpsJwksFactory); - verificationKeyResolver = new JWTVerificationkeyResolver(issuerConfig); + if (primary.isValid()) { + log.debug("Found issuer in top level config"); + primary.init(); + return Optional.of(primary); + } else { + 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); } - initConsumer(); - log.debug("JWK configured"); } - @SuppressWarnings("unchecked") - JsonWebKeySet parseJwkSet(Map jwkObj) throws JoseException { - JsonWebKeySet webKeySet = new JsonWebKeySet(); - if (jwkObj.containsKey("keys")) { - List jwkList = (List) jwkObj.get("keys"); - for (Object jwkO : jwkList) { - webKeySet.addJsonWebKey(JsonWebKey.Factory.newJwk((Map) jwkO)); - } - } else { - webKeySet = new JsonWebKeySet(JsonWebKey.Factory.newJwk(jwkObj)); + /** + * 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 parseIssuers(Map pluginConfig) { + List configs = new ArrayList<>(); + try { + List> issuers = (List>) 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()) { - httpsJwks.refresh(); - } - authResponse = authenticate(header); - } 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 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); // Retry + exceptionMessage = authResponse.getJwtException() != null ? authResponse.getJwtException().getMessage() : ""; + } + } + } switch (authResponse.getAuthCode()) { case AUTHENTICATED: @@ -392,74 +384,68 @@ 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 { try { - String jwtCompact = st.nextToken(); - 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"); - } - if (claimsMatchCompiled != null) { - for (Map.Entry entry : claimsMatchCompiled.entrySet()) { - String claim = entry.getKey(); - if (jwtClaims.hasClaim(claim)) { - if (!entry.getValue().matcher(jwtClaims.getStringClaimValue(claim)).matches()) { - return new JWTAuthenticationResponse(AuthCode.CLAIM_MISMATCH, - "Claim " + claim + "=" + jwtClaims.getStringClaimValue(claim) - + " 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.hasClaim(CLAIM_SCOPE)) { - // Fail if we require scopes but they don't exist - return new JWTAuthenticationResponse(AuthCode.CLAIM_MISMATCH, "Claim " + CLAIM_SCOPE + " is required but does not exist in JWT"); - } - Set scopes = Collections.emptySet(); - 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)) { - return new JWTAuthenticationResponse(AuthCode.SCOPE_MISSING, "Claim " + CLAIM_SCOPE + " does not contain any of the required scopes: " + requiredScopes); - } - } - final Set finalScopes = new HashSet<>(scopes); - finalScopes.remove("openid"); // Remove standard scope - // Pass scopes with principal to signal to any Authorization plugins that user has some verified role claims - return new JWTAuthenticationResponse(AuthCode.AUTHENTICATED, new JWTPrincipalWithUserRoles(principal, jwtCompact, jwtClaims.getClaimsMap(), finalScopes)); - } 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()); - } - return new JWTAuthenticationResponse(AuthCode.JWT_VALIDATION_EXCEPTION, e); + 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"); } - } catch (MalformedClaimException e) { - return new JWTAuthenticationResponse(AuthCode.JWT_PARSE_ERROR, "Malformed claim, error was: " + e.getMessage()); + if (claimsMatchCompiled != null) { + for (Map.Entry entry : claimsMatchCompiled.entrySet()) { + String claim = entry.getKey(); + if (jwtClaims.hasClaim(claim)) { + if (!entry.getValue().matcher(jwtClaims.getStringClaimValue(claim)).matches()) { + return new JWTAuthenticationResponse(AuthCode.CLAIM_MISMATCH, + "Claim " + claim + "=" + jwtClaims.getStringClaimValue(claim) + + " 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.hasClaim(CLAIM_SCOPE)) { + // Fail if we require scopes but they don't exist + return new JWTAuthenticationResponse(AuthCode.CLAIM_MISMATCH, "Claim " + CLAIM_SCOPE + " is required but does not exist in JWT"); + } + Set scopes = Collections.emptySet(); + 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)) { + return new JWTAuthenticationResponse(AuthCode.SCOPE_MISSING, "Claim " + CLAIM_SCOPE + " does not contain any of the required scopes: " + requiredScopes); + } + } + final Set finalScopes = new HashSet<>(scopes); + finalScopes.remove("openid"); // Remove standard scope + // Pass scopes with principal to signal to any Authorization plugins that user has some verified role claims + return new JWTAuthenticationResponse(AuthCode.AUTHENTICATED, new JWTPrincipalWithUserRoles(principal, jwtCompact, jwtClaims.getClaimsMap(), finalScopes)); + } 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()); + } + return new JWTAuthenticationResponse(AuthCode.JWT_VALIDATION_EXCEPTION, e); } - } else { - return new JWTAuthenticationResponse(AuthCode.AUTZ_HEADER_PROBLEM, "Authorization header is not in correct format"); + } catch (MalformedClaimException e) { + return new JWTAuthenticationResponse(AuthCode.JWT_PARSE_ERROR, "Malformed claim, error was: " + e.getMessage()); } } else { return new JWTAuthenticationResponse(AuthCode.AUTZ_HEADER_PROBLEM, "Authorization header is not in correct format"); @@ -475,18 +461,31 @@ 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(); + jwtConsumerBuilder.setRequireSubject(); if (requireExpirationTime) jwtConsumerBuilder.setRequireExpirationTime(); if (algWhitelist != null) @@ -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 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 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 securityConf; - - WellKnownDiscoveryConfig(Map 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) 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 getScopesSupported() { - return (List) securityConf.get("scopes_supported"); - } - - @SuppressWarnings("unchecked") - public List getResponseTypesSupported() { - return (List) 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 jwksUrl; - private List httpsJwks; - - /** - * Create config - * @param iss unique issuer id string - * @param jwksUrls list of URLs for JWKs endpoints - */ - public IssuerConfig(String iss, List 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 getJwksUrl() { - return jwksUrl; - } - - public List 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) { - 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 createList(List 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 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); + } } diff --git a/solr/core/src/java/org/apache/solr/security/JWTIssuerConfig.java b/solr/core/src/java/org/apache/solr/security/JWTIssuerConfig.java new file mode 100644 index 00000000000..e62915ceb39 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/security/JWTIssuerConfig.java @@ -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 jwksUrl; + private List 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 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 configMap) { + HashMap 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) 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 jwkObj) throws JoseException { + JsonWebKeySet webKeySet = new JsonWebKeySet(); + if (jwkObj.containsKey("keys")) { + List jwkList = (List) jwkObj.get("keys"); + for (Object jwkO : jwkList) { + webKeySet.addJsonWebKey(JsonWebKey.Factory.newJwk((Map) 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 getJwksUrls() { + return jwksUrl; + } + + public JWTIssuerConfig setJwksUrl(List 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) 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 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 asConfig() { + HashMap 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 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 createList(List 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 securityConf; + + WellKnownDiscoveryConfig(Map 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) 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 getScopesSupported() { + return (List) securityConf.get("scopes_supported"); + } + + @SuppressWarnings("unchecked") + public List getResponseTypesSupported() { + return (List) securityConf.get("response_types_supported"); + } + } +} diff --git a/solr/core/src/java/org/apache/solr/security/JWTVerificationkeyResolver.java b/solr/core/src/java/org/apache/solr/security/JWTVerificationkeyResolver.java index 09b33d493e0..50fb4ad052b 100644 --- a/solr/core/src/java/org/apache/solr/security/JWTVerificationkeyResolver.java +++ b/solr/core/src/java/org/apache/solr/security/JWTVerificationkeyResolver.java @@ -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 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 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 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 - for (HttpsJwks hjwks : issuerConfig.getHttpsJwks()) { - jsonWebKeys.addAll(hjwks.getJsonWebKeys()); + 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 getIssuerConfigs() { + return new HashSet<>(issuerConfigs.values()); } } diff --git a/solr/core/src/test-files/solr/security/jwt_plugin_jwk_security.json b/solr/core/src/test-files/solr/security/jwt_plugin_jwk_security.json index 7daab7ac9cb..772089e3819 100644 --- a/solr/core/src/test-files/solr/security/jwt_plugin_jwk_security.json +++ b/solr/core/src/test-files/solr/security/jwt_plugin_jwk_security.json @@ -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" } } \ No newline at end of file diff --git a/solr/core/src/test-files/solr/security/jwt_plugin_jwk_url_security.json b/solr/core/src/test-files/solr/security/jwt_plugin_jwk_url_security.json index 74b86ef03c1..24f587683ff 100644 --- a/solr/core/src/test-files/solr/security/jwt_plugin_jwk_url_security.json +++ b/solr/core/src/test-files/solr/security/jwt_plugin_jwk_url_security.json @@ -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" } } \ No newline at end of file diff --git a/solr/core/src/test/org/apache/solr/security/JWTAuthPluginIntegrationTest.java b/solr/core/src/test/org/apache/solr/security/JWTAuthPluginIntegrationTest.java index cb0f655f1c3..20dc667c6ac 100644 --- a/solr/core/src/test/org/apache/solr/security/JWTAuthPluginIntegrationTest.java +++ b/solr/core/src/test/org/apache/solr/security/JWTAuthPluginIntegrationTest.java @@ -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 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 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 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 post(String url, String json, String token) throws IOException { URL createUrl = new URL(url); HttpURLConnection con = (HttpURLConnection) createUrl.openConnection(); diff --git a/solr/core/src/test/org/apache/solr/security/JWTAuthPluginTest.java b/solr/core/src/test/org/apache/solr/security/JWTAuthPluginTest.java index c3b28076c16..5ed1032c2f3 100644 --- a/solr/core/src/test/org/apache/solr/security/JWTAuthPluginTest.java +++ b/solr/core/src/test/org/apache/solr/security/JWTAuthPluginTest.java @@ -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 testJwk; private static RsaJsonWebKey rsaJsonWebKey; private HashMap testConfig; private HashMap minimalConfig; + // Shared with other tests + static HashMap 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 { - // Generate an RSA key pair, which will be used for signing and verification of the JWT, wrapped in a JWK - rsaJsonWebKey = RsaJwkGenerator.generateJwk(2048); - rsaJsonWebKey.setKeyId("k1"); - JwtClaims claims = generateClaims(); JsonWebSignature jws = new JsonWebSignature(); jws.setPayload(claims.toJson()); @@ -74,7 +89,7 @@ public class JWTAuthPluginTest extends SolrTestCaseJ4 { String testJwt = jws.getCompactSerialization(); testHeader = "Bearer" + " " + testJwt; - + claims.unsetClaim("iss"); claims.unsetClaim("aud"); claims.unsetClaim("exp"); @@ -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 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 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 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 testJwks = new HashMap<>(); - List> 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"); - plugin.init(testConfig); - } - @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 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 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()); + 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(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 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 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()); + } } diff --git a/solr/core/src/test/org/apache/solr/security/JWTIssuerConfigTest.java b/solr/core/src/test/org/apache/solr/security/JWTIssuerConfigTest.java new file mode 100644 index 00000000000..33885524831 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/security/JWTIssuerConfigTest.java @@ -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 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 testJwks = new HashMap<>(); + List> 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 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"); + } +} \ No newline at end of file diff --git a/solr/core/src/test/org/apache/solr/security/JWTVerificationkeyResolverTest.java b/solr/core/src/test/org/apache/solr/security/JWTVerificationkeyResolverTest.java index d4660c57013..4b88787b0ea 100644 --- a/solr/core/src/test/org/apache/solr/security/JWTVerificationkeyResolverTest.java +++ b/solr/core/src/test/org/apache/solr/security/JWTVerificationkeyResolverTest.java @@ -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(); } diff --git a/solr/solr-ref-guide/src/jwt-authentication-plugin.adoc b/solr/solr-ref-guide/src/jwt-authentication-plugin.adoc index 498152d8824..b67f2f97654 100644 --- a/solr/solr-ref-guide/src/jwt-authentication-plugin.adoc +++ b/solr/solr-ref-guide/src/jwt-authentication-plugin.adoc @@ -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 <>. == 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 <> 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> - "e": "AQAB", - "kid": "k1", - "kty": "RSA", - "n": "3ZF6wBGPMsLzsS1KLghxaVpZtXD3nTLzDm0c974i9-KNU_1rhhBeiVfS64VfEQmP8SA470jEy7yWcvnz9GvG-YAlm9iOwVF7jLl2awdws0ocFjdSPT3SjPQKzOeMO7G9XqNTkrvoFCn1YAi26fbhhcqkwZDoeTcHQdRN32frzccuPhZrwImApIedroKLlKWv2IvPDnz2Bpe2WWVc2HdoWYqEVD3p_BEy8f-RTSHK3_8kDDF9yAwI9jx7CK1_C-eYxXltm-6rpS5NGyFm0UNTZMxVU28Tl7LX8Vb6CikyCQ9YRCtk_CvpKWmEuKEp9I28KHQNmGkDYT90nt3vjbCXxw" - }, - "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> + "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": "3ZF6w....vjbCXxw" + }, + "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