blob: 238ac605a6a645f00bc7e5c7209ffc05e500582c [file] [log] [blame]
/*
* 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 javax.servlet.FilterChain;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.lang.invoke.MethodHandles;
import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
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;
import java.util.stream.Collectors;
import com.google.common.collect.ImmutableSet;
import org.apache.http.HttpHeaders;
import org.apache.http.HttpRequest;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.protocol.HttpContext;
import org.apache.solr.client.solrj.impl.Http2SolrClient;
import org.apache.solr.common.SolrException;
import org.apache.solr.common.SpecProvider;
import org.apache.solr.common.StringUtils;
import org.apache.solr.common.util.Base64;
import org.apache.solr.common.util.CommandOperation;
import org.apache.solr.common.util.Utils;
import org.apache.solr.common.util.ValidatingJsonMap;
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.jwt.JwtClaims;
import org.jose4j.jwt.MalformedClaimException;
import org.jose4j.jwt.consumer.InvalidJwtException;
import org.jose4j.jwt.consumer.InvalidJwtSignatureException;
import org.jose4j.jwt.consumer.JwtConsumer;
import org.jose4j.jwt.consumer.JwtConsumerBuilder;
import org.jose4j.lang.JoseException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Authenticaion plugin that finds logged in user by validating the signature of a JWT token
*/
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_REQUIRE_ISSUER = "requireIss";
private static final String PARAM_PRINCIPAL_CLAIM = "principalClaim";
private static final String PARAM_ROLES_CLAIM = "rolesClaim";
private static final String PARAM_REQUIRE_EXPIRATIONTIME = "requireExp";
private static final String PARAM_ALG_WHITELIST = "algWhitelist";
private static final String PARAM_JWK_CACHE_DURATION = "jwkCacheDur";
private static final String PARAM_CLAIMS_MATCH = "claimsMatch";
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_ISSUERS = "issuers";
private static final String PARAM_REALM = "realm";
private static final String DEFAULT_AUTH_REALM = "solr-jwt";
private static final String CLAIM_SCOPE = "scope";
private static final long RETRY_INIT_DELAY_SECONDS = 30;
private static final long DEFAULT_REFRESH_REPRIEVE_THRESHOLD = 5000;
static final String PRIMARY_ISSUER = "PRIMARY";
private static final Set<String> PROPS = ImmutableSet.of(PARAM_BLOCK_UNKNOWN,
PARAM_PRINCIPAL_CLAIM, PARAM_REQUIRE_EXPIRATIONTIME, PARAM_ALG_WHITELIST,
PARAM_JWK_CACHE_DURATION, PARAM_CLAIMS_MATCH, PARAM_SCOPE, PARAM_REALM, PARAM_ROLES_CLAIM,
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 boolean requireExpirationTime;
private List<String> algWhitelist;
private String principalClaim;
private String rolesClaim;
private HashMap<String, Pattern> claimsMatchCompiled;
private boolean blockUnknown;
private List<String> requiredScopes = new ArrayList<>();
private Map<String, Object> pluginConfig;
private Instant lastInitTime = Instant.now();
private String adminUiScope;
private List<String> redirectUris;
private List<JWTIssuerConfig> issuerConfigs;
private boolean requireIssuer;
private JWTVerificationkeyResolver verificationKeyResolver;
String realm;
/**
* Initialize plugin
*/
public JWTAuthPlugin() {}
@SuppressWarnings("unchecked")
@Override
public void init(Map<String, Object> pluginConfig) {
this.pluginConfig = pluginConfig;
this.issuerConfigs = null;
List<String> unknownKeys = pluginConfig.keySet().stream().filter(k -> !PROPS.contains(k)).collect(Collectors.toList());
unknownKeys.remove("class");
unknownKeys.remove("");
if (!unknownKeys.isEmpty()) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Invalid JwtAuth configuration parameter " + unknownKeys);
}
blockUnknown = Boolean.parseBoolean(String.valueOf(pluginConfig.getOrDefault(PARAM_BLOCK_UNKNOWN, false)));
requireIssuer = Boolean.parseBoolean(String.valueOf(pluginConfig.getOrDefault(PARAM_REQUIRE_ISSUER, "true")));
requireExpirationTime = Boolean.parseBoolean(String.valueOf(pluginConfig.getOrDefault(PARAM_REQUIRE_EXPIRATIONTIME, "true")));
principalClaim = (String) pluginConfig.getOrDefault(PARAM_PRINCIPAL_CLAIM, "sub");
rolesClaim = (String) pluginConfig.get(PARAM_ROLES_CLAIM);
algWhitelist = (List<String>) pluginConfig.get(PARAM_ALG_WHITELIST);
realm = (String) pluginConfig.getOrDefault(PARAM_REALM, DEFAULT_AUTH_REALM);
Map<String, String> claimsMatch = (Map<String, String>) pluginConfig.get(PARAM_CLAIMS_MATCH);
claimsMatchCompiled = new HashMap<>();
if (claimsMatch != null) {
for (Map.Entry<String, String> entry : claimsMatch.entrySet()) {
claimsMatchCompiled.put(entry.getKey(), Pattern.compile(entry.getValue()));
}
}
String requiredScopesStr = (String) pluginConfig.get(PARAM_SCOPE);
if (!StringUtils.isEmpty(requiredScopesStr)) {
requiredScopes = Arrays.asList(requiredScopesStr.split("\\s+"));
}
long jwkCacheDuration = Long.parseLong((String) pluginConfig.getOrDefault(PARAM_JWK_CACHE_DURATION, "3600"));
JWTIssuerConfig.setHttpsJwksFactory(new JWTIssuerConfig.HttpsJwksFactory(jwkCacheDuration, DEFAULT_REFRESH_REPRIEVE_THRESHOLD));
issuerConfigs = new ArrayList<>();
// Try to parse an issuer from top level config, and add first (primary issuer)
Optional<JWTIssuerConfig> topLevelIssuer = parseIssuerFromTopLevelConfig(pluginConfig);
topLevelIssuer.ifPresent(ic -> {
issuerConfigs.add(ic);
log.warn("JWTAuthPlugin issuer is configured using top-level configuration keys. Please consider using the 'issuers' array instead.");
});
// Add issuers from 'issuers' key
issuerConfigs.addAll(parseIssuers(pluginConfig));
verificationKeyResolver = new JWTVerificationkeyResolver(issuerConfigs, requireIssuer);
if (issuerConfigs.size() > 0 && getPrimaryIssuer().getAuthorizationEndpoint() != null) {
adminUiScope = (String) pluginConfig.get(PARAM_ADMINUI_SCOPE);
if (adminUiScope == null && requiredScopes.size() > 0) {
adminUiScope = requiredScopes.get(0);
log.warn("No adminUiScope given, using first scope in 'scope' list as required scope for accessing Admin UI");
}
if (adminUiScope == null) {
adminUiScope = "solr";
log.info("No adminUiScope provided, fallback to 'solr' as required scope for Admin UI login may not work");
}
Object redirectUrisObj = pluginConfig.get(PARAM_REDIRECT_URIS);
redirectUris = Collections.emptyList();
if (redirectUrisObj != null) {
if (redirectUrisObj instanceof String) {
redirectUris = Collections.singletonList((String) redirectUrisObj);
} else if (redirectUrisObj instanceof List) {
redirectUris = (List<String>) redirectUrisObj;
}
}
}
initConsumer();
lastInitTime = Instant.now();
}
@SuppressWarnings("unchecked")
private Optional<JWTIssuerConfig> parseIssuerFromTopLevelConfig(Map<String, Object> 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);
}
JWTIssuerConfig primary = new JWTIssuerConfig(PRIMARY_ISSUER)
.setIss((String) conf.get(JWTIssuerConfig.PARAM_ISSUER))
.setAud((String) conf.get(JWTIssuerConfig.PARAM_AUDIENCE))
.setJwksUrl(conf.get(JWTIssuerConfig.PARAM_JWKS_URL) != null ? conf.get(JWTIssuerConfig.PARAM_JWKS_URL) : conf.get(JWTIssuerConfig.PARAM_JWK_URL))
.setAuthorizationEndpoint((String) conf.get(JWTIssuerConfig.PARAM_AUTHORIZATION_ENDPOINT))
.setClientId((String) conf.get(JWTIssuerConfig.PARAM_CLIENT_ID))
.setWellKnownUrl((String) conf.get(JWTIssuerConfig.PARAM_WELL_KNOWN_URL));
if (conf.get(JWTIssuerConfig.PARAM_JWK) != null) {
primary.setJsonWebKeySet(JWTIssuerConfig.parseJwkSet((Map<String, Object>) conf.get(JWTIssuerConfig.PARAM_JWK)));
}
if (primary.isValid()) {
log.debug("Found issuer in top level config");
primary.init();
return Optional.of(primary);
} else {
log.debug("No issuer configured in top level config");
return Optional.empty();
}
} catch (JoseException je) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Failed parsing issuer from top level config", je);
}
}
/**
* Fetch the primary issuer to be used for Admin UI authentication. Callers of this method must ensure that at least
* one issuer is configured. The primary issuer is defined as the first issuer configured in the list.
* @return JWTIssuerConfig object for the primary issuer
*/
JWTIssuerConfig getPrimaryIssuer() {
if (issuerConfigs.size() == 0) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "No issuers configured");
}
return issuerConfigs.get(0);
}
/**
* Initialize optional additional issuers configured in 'issuers' config map
* @param pluginConfig the main config object
* @return a list of parsed {@link JWTIssuerConfig} objects
*/
@SuppressWarnings("unchecked")
List<JWTIssuerConfig> parseIssuers(Map<String, Object> pluginConfig) {
List<JWTIssuerConfig> configs = new ArrayList<>();
try {
List<Map<String, Object>> issuers = (List<Map<String, Object>>) pluginConfig.get(PARAM_ISSUERS);
if (issuers != null) {
issuers.forEach(issuerConf -> {
JWTIssuerConfig ic = new JWTIssuerConfig(issuerConf);
ic.init();
configs.add(ic);
if (log.isDebugEnabled()) {
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);
}
}
/**
* Main authentication method that looks for correct JWT token in the Authorization header
*/
@Override
public boolean doAuthenticate(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws Exception {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (jwtConsumer == null) {
if (header == null && !blockUnknown) {
log.info("JWTAuth not configured, but allowing anonymous access since {}==false", PARAM_BLOCK_UNKNOWN);
numPassThrough.inc();
filterChain.doFilter(request, response);
return true;
}
// Retry config
if (lastInitTime.plusSeconds(RETRY_INIT_DELAY_SECONDS).isAfter(Instant.now())) {
log.info("Retrying JWTAuthPlugin initialization (retry delay={}s)", RETRY_INIT_DELAY_SECONDS);
init(pluginConfig);
}
if (jwtConsumer == null) {
log.warn("JWTAuth not configured");
numErrors.mark();
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "JWTAuth plugin not correctly configured");
}
}
JWTAuthenticationResponse 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<JWTIssuerConfig> issuerConfig = issuerConfigs.stream().filter(ic -> issuer.equals(ic.getIss())).findFirst();
if (issuerConfig.isPresent() && issuerConfig.get().usesHttpsJwk()) {
log.info("Signature validation failed for issuer {}. Refreshing JWKs from IdP before trying again: {}",
issuer, exceptionMessage);
for (HttpsJwks httpsJwks : issuerConfig.get().getHttpsJwks()) {
httpsJwks.refresh();
}
authResponse = authenticate(header); // Retry
exceptionMessage = authResponse.getJwtException() != null ? authResponse.getJwtException().getMessage() : "";
}
}
}
switch (authResponse.getAuthCode()) {
case AUTHENTICATED:
final Principal principal = authResponse.getPrincipal();
HttpServletRequestWrapper wrapper = new HttpServletRequestWrapper(request) {
@Override
public Principal getUserPrincipal() {
return principal;
}
};
if (!(principal instanceof JWTPrincipal)) {
numErrors.mark();
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "JWTAuth plugin says AUTHENTICATED but no token extracted");
}
if (log.isDebugEnabled())
log.debug("Authentication SUCCESS");
numAuthenticated.inc();
filterChain.doFilter(wrapper, response);
return true;
case PASS_THROUGH:
if (log.isDebugEnabled())
log.debug("Unknown user, but allow due to {}=false", PARAM_BLOCK_UNKNOWN);
numPassThrough.inc();
request.setAttribute(AuthenticationPlugin.class.getName(), getPromptHeaders(null, null));
filterChain.doFilter(request, response);
return true;
case AUTZ_HEADER_PROBLEM:
case JWT_PARSE_ERROR:
log.warn("Authentication failed. {}, {}", authResponse.getAuthCode(), authResponse.getAuthCode().getMsg());
numErrors.mark();
authenticationFailure(response, authResponse.getAuthCode().getMsg(), HttpServletResponse.SC_BAD_REQUEST, BearerWwwAuthErrorCode.invalid_request);
return false;
case CLAIM_MISMATCH:
case JWT_EXPIRED:
case JWT_VALIDATION_EXCEPTION:
case PRINCIPAL_MISSING:
log.warn("Authentication failed. {}, {}", authResponse.getAuthCode(), exceptionMessage);
numWrongCredentials.inc();
authenticationFailure(response, authResponse.getAuthCode().getMsg(), HttpServletResponse.SC_UNAUTHORIZED, BearerWwwAuthErrorCode.invalid_token);
return false;
case SIGNATURE_INVALID:
log.warn("Signature validation failed: {}", exceptionMessage);
numWrongCredentials.inc();
authenticationFailure(response, authResponse.getAuthCode().getMsg(), HttpServletResponse.SC_UNAUTHORIZED, BearerWwwAuthErrorCode.invalid_token);
return false;
case SCOPE_MISSING:
numWrongCredentials.inc();
authenticationFailure(response, authResponse.getAuthCode().getMsg(), HttpServletResponse.SC_UNAUTHORIZED, BearerWwwAuthErrorCode.insufficient_scope);
return false;
case NO_AUTZ_HEADER:
default:
numMissingCredentials.inc();
authenticationFailure(response, authResponse.getAuthCode().getMsg(), HttpServletResponse.SC_UNAUTHORIZED, null);
return false;
}
}
/**
* Testable authentication method
*
* @param authorizationHeader the http header "Authentication"
* @return AuthenticationResponse object
*/
protected JWTAuthenticationResponse authenticate(String authorizationHeader) {
if (authorizationHeader != null) {
String jwtCompact = parseAuthorizationHeader(authorizationHeader);
if (jwtCompact != null) {
try {
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<String, Pattern> 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");
}
// Find scopes for user
Set<String> 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);
}
}
}
// Determine roles of user, either from 'rolesClaim' or from 'scope' as parsed above
final Set<String> finalRoles = new HashSet<>();
if (rolesClaim == null) {
// Pass scopes with principal to signal to any Authorization plugins that user has some verified role claims
finalRoles.addAll(scopes);
finalRoles.remove("openid"); // Remove standard scope
} else {
// Pull roles from separate claim, either as whitespace separated list or as JSON array
Object rolesObj = jwtClaims.getClaimValue(rolesClaim);
if (rolesObj != null) {
if (rolesObj instanceof String) {
finalRoles.addAll(Arrays.asList(((String) rolesObj).split("\\s+")));
} else if (rolesObj instanceof List) {
finalRoles.addAll(jwtClaims.getStringListClaimValue(rolesClaim));
}
}
}
if (finalRoles.size() > 0) {
return new JWTAuthenticationResponse(AuthCode.AUTHENTICATED, new JWTPrincipalWithUserRoles(principal, jwtCompact, jwtClaims.getClaimsMap(), finalRoles));
} else {
return new JWTAuthenticationResponse(AuthCode.AUTHENTICATED, new JWTPrincipal(principal, jwtCompact, jwtClaims.getClaimsMap()));
}
} catch (InvalidJwtSignatureException ise) {
return new JWTAuthenticationResponse(AuthCode.SIGNATURE_INVALID, ise);
} catch (InvalidJwtException e) {
// Whether or not the JWT has expired being one common reason for invalidity
if (e.hasExpired()) {
return new JWTAuthenticationResponse(AuthCode.JWT_EXPIRED, "Authentication failed due to expired JWT token. Expired at " + e.getJwtContext().getJwtClaims().getExpirationTime());
}
if (e.getCause() != null && e.getCause() instanceof JoseException && e.getCause().getMessage().contains("Invalid JOSE Compact Serialization")) {
return new JWTAuthenticationResponse(AuthCode.JWT_PARSE_ERROR, e.getCause().getMessage());
}
return new JWTAuthenticationResponse(AuthCode.JWT_VALIDATION_EXCEPTION, e);
}
} 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");
}
} else {
// No Authorization header
if (blockUnknown) {
return new JWTAuthenticationResponse(AuthCode.NO_AUTZ_HEADER, "Missing Authorization header");
} else {
log.debug("No user authenticated, but blockUnknown=false, so letting request through");
return new JWTAuthenticationResponse(AuthCode.PASS_THROUGH);
}
}
}
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
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 (requireExpirationTime)
jwtConsumerBuilder.setRequireExpirationTime();
if (algWhitelist != null)
jwtConsumerBuilder.setJwsAlgorithmConstraints( // only allow the expected signature algorithm(s) in the given context
new AlgorithmConstraints(AlgorithmConstraints.ConstraintType.WHITELIST, algWhitelist.toArray(new String[0])));
jwtConsumerBuilder.setVerificationKeyResolver(verificationKeyResolver);
jwtConsumer = jwtConsumerBuilder.build(); // create the JwtConsumer instance
}
@Override
public void close() throws IOException {
jwtConsumer = null;
}
@Override
public ValidatingJsonMap getSpec() {
return Utils.getSpec("cluster.security.JwtAuth.Commands").getSpec();
}
/**
* Operate the commands on the latest conf and return a new conf object
* If there are errors in the commands , throw a SolrException. return a null
* if no changes are to be made as a result of this edit. It is the responsibility
* of the implementation to ensure that the returned config is valid . The framework
* does no validation of the data
*
* @param latestConf latest version of config
* @param commands the list of command operations to perform
*/
@Override
public Map<String, Object> edit(Map<String, Object> latestConf, List<CommandOperation> commands) {
for (CommandOperation command : commands) {
if (command.name.equals("set-property")) {
for (Map.Entry<String, Object> e : command.getDataMap().entrySet()) {
if (PROPS.contains(e.getKey())) {
latestConf.put(e.getKey(), e.getValue());
return latestConf;
} else {
command.addError("Unknown property " + e.getKey());
}
}
}
}
if (!CommandOperation.captureErrors(commands).isEmpty()) return null;
return latestConf;
}
private enum BearerWwwAuthErrorCode { invalid_request, invalid_token, insufficient_scope}
private void authenticationFailure(HttpServletResponse response, String message, int httpCode, BearerWwwAuthErrorCode responseError) throws IOException {
getPromptHeaders(responseError, message).forEach(response::setHeader);
response.sendError(httpCode, message);
log.info("JWT Authentication attempt failed: {}", message);
}
/**
* Generate proper response prompt headers
* @param responseError standardized error code. Set to 'null' to generate WWW-Authenticate header with no error
* @param message custom message string to return in www-authenticate, or null if no error
* @return map of headers to add to response
*/
private Map<String, String> getPromptHeaders(BearerWwwAuthErrorCode responseError, String message) {
Map<String,String> headers = new HashMap<>();
List<String> wwwAuthParams = new ArrayList<>();
wwwAuthParams.add("Bearer realm=\"" + realm + "\"");
if (responseError != null) {
wwwAuthParams.add("error=\"" + responseError + "\"");
wwwAuthParams.add("error_description=\"" + message + "\"");
}
headers.put(HttpHeaders.WWW_AUTHENTICATE, String.join(", ", wwwAuthParams));
headers.put(AuthenticationPlugin.HTTP_HEADER_X_SOLR_AUTHDATA, generateAuthDataHeader());
return headers;
}
protected String generateAuthDataHeader() {
JWTIssuerConfig primaryIssuer = getPrimaryIssuer();
Map<String,Object> data = new HashMap<>();
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);
return Base64.byteArrayToBase64(headerJson.getBytes(StandardCharsets.UTF_8));
}
/**
* Response for authentication attempt
*/
static class JWTAuthenticationResponse {
private final Principal principal;
private String errorMessage;
private AuthCode authCode;
private InvalidJwtException jwtException;
enum AuthCode {
PASS_THROUGH("No user, pass through"), // Returned when no user authentication but block_unknown=false
AUTHENTICATED("Authenticated"), // Returned when authentication OK
PRINCIPAL_MISSING("No principal in JWT"), // JWT token does not contain necessary principal (typically sub)
JWT_PARSE_ERROR("Invalid JWT"), // Problems with parsing the JWT itself
AUTZ_HEADER_PROBLEM("Wrong header"), // The Authorization header exists but is not correct
NO_AUTZ_HEADER("Require authentication"), // The Authorization header is missing
JWT_EXPIRED("JWT token expired"), // JWT token has expired
CLAIM_MISMATCH("Required JWT claim missing"), // Some required claims are missing or wrong
JWT_VALIDATION_EXCEPTION("JWT validation failed"), // The JWT parser failed validation. More details in exception
SCOPE_MISSING("Required scope missing in JWT"), // None of the required scopes were present in JWT
SIGNATURE_INVALID("Signature invalid"); // Validation of JWT signature failed
public String getMsg() {
return msg;
}
private final String msg;
AuthCode(String msg) {
this.msg = msg;
}
}
JWTAuthenticationResponse(AuthCode authCode, InvalidJwtException e) {
this.authCode = authCode;
this.jwtException = e;
principal = null;
this.errorMessage = e.getMessage();
}
JWTAuthenticationResponse(AuthCode authCode, String errorMessage) {
this.authCode = authCode;
this.errorMessage = errorMessage;
principal = null;
}
JWTAuthenticationResponse(AuthCode authCode, Principal principal) {
this.authCode = authCode;
this.principal = principal;
}
JWTAuthenticationResponse(AuthCode authCode) {
this.authCode = authCode;
principal = null;
}
boolean isAuthenticated() {
return authCode.equals(AuthCode.AUTHENTICATED);
}
public Principal getPrincipal() {
return principal;
}
String getErrorMessage() {
return errorMessage;
}
InvalidJwtException getJwtException() {
return jwtException;
}
AuthCode getAuthCode() {
return authCode;
}
}
@Override
protected boolean interceptInternodeRequest(HttpRequest httpRequest, HttpContext httpContext) {
if (httpContext instanceof HttpClientContext) {
HttpClientContext httpClientContext = (HttpClientContext) httpContext;
if (httpClientContext.getUserToken() instanceof JWTPrincipal) {
JWTPrincipal jwtPrincipal = (JWTPrincipal) httpClientContext.getUserToken();
httpRequest.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + jwtPrincipal.getToken());
return true;
}
}
return false;
}
@Override
protected boolean interceptInternodeRequest(Request request) {
Object userToken = request.getAttributes().get(Http2SolrClient.REQ_PRINCIPAL_KEY);
if (userToken instanceof JWTPrincipal) {
JWTPrincipal jwtPrincipal = (JWTPrincipal) userToken;
request.header(HttpHeaders.AUTHORIZATION, "Bearer " + jwtPrincipal.getToken());
return true;
}
return false;
}
public List<JWTIssuerConfig> getIssuerConfigs() {
return issuerConfigs;
}
/**
* Lookup issuer config by its name
* @param name name property of config
* @return issuer config object or null if not found
*/
public JWTIssuerConfig getIssuerConfigByName(String name) {
return issuerConfigs.stream().filter(ic -> name.equals(ic.getName())).findAny().orElse(null);
}
}