blob: e5d42ca8f81850f88a489e2c75b00fe234667cf5 [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.jwt;
import java.io.IOException;
import java.io.InputStream;
import java.lang.invoke.MethodHandles;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.Principal;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collection;
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 javax.servlet.FilterChain;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.io.IOUtils;
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.api.AnnotatedApi;
import org.apache.solr.api.Api;
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.CommandOperation;
import org.apache.solr.common.util.Utils;
import org.apache.solr.common.util.ValidatingJsonMap;
import org.apache.solr.core.CoreContainer;
import org.apache.solr.handler.admin.api.ModifyJWTAuthPluginConfigAPI;
import org.apache.solr.security.AuthenticationPlugin;
import org.apache.solr.security.ConfigEditablePlugin;
import org.apache.solr.security.jwt.JWTAuthPlugin.JWTAuthenticationResponse.AuthCode;
import org.apache.solr.util.CryptoKeys;
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_ALLOWLIST = "algAllowlist";
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 PARAM_TRUSTED_CERTS_FILE = "trustedCertsFile";
private static final String PARAM_TRUSTED_CERTS = "trustedCerts";
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";
@Deprecated(since = "9.0") // Remove in 10.0
private static final String PARAM_ALG_WHITELIST = "algWhitelist";
private static final Set<String> PROPS =
Set.of(
PARAM_BLOCK_UNKNOWN,
PARAM_PRINCIPAL_CLAIM,
PARAM_REQUIRE_EXPIRATIONTIME,
PARAM_ALG_ALLOWLIST,
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,
PARAM_TRUSTED_CERTS_FILE,
PARAM_TRUSTED_CERTS,
// These keys are supported for now to enable PRIMARY issuer config through top-level keys
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> algAllowlist;
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;
private Collection<X509Certificate> trustedSslCerts;
String realm;
private final CoreContainer coreContainer;
/** Initialize plugin */
public JWTAuthPlugin() {
this(null);
}
public JWTAuthPlugin(CoreContainer coreContainer) {
this.coreContainer = coreContainer;
}
@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);
algAllowlist = (List<String>) pluginConfig.get(PARAM_ALG_ALLOWLIST);
// TODO: Remove deprecated warning in Solr 10.0
if ((algAllowlist == null || algAllowlist.isEmpty())
&& pluginConfig.containsKey(PARAM_ALG_WHITELIST)) {
log.warn(
"Found use of deprecated parameter algWhitelist. Please use {} instead.",
PARAM_ALG_ALLOWLIST);
algAllowlist = (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+"));
}
// Parse custom IDP SSL Cert from either path or string
InputStream trustedCertsStream = null;
String trustedCertsFile = (String) pluginConfig.get(PARAM_TRUSTED_CERTS_FILE);
String trustedCerts = (String) pluginConfig.get(PARAM_TRUSTED_CERTS);
if (trustedCertsFile != null && trustedCerts != null) {
throw new SolrException(
SolrException.ErrorCode.SERVER_ERROR,
"Found both "
+ PARAM_TRUSTED_CERTS_FILE
+ " and "
+ PARAM_TRUSTED_CERTS
+ ", please use only one");
}
if (trustedCertsFile != null) {
try {
Path trustedCertsPath = Paths.get(trustedCertsFile);
if (coreContainer != null) {
coreContainer.assertPathAllowed(trustedCertsPath);
}
trustedCertsStream = Files.newInputStream(trustedCertsPath);
log.info("Reading trustedCerts from file {}", trustedCertsFile);
} catch (IOException e) {
throw new SolrException(
SolrException.ErrorCode.SERVER_ERROR, "Failed to read file " + trustedCertsFile, e);
}
}
if (trustedCerts != null) {
log.info("Reading trustedCerts PEM from configuration string");
trustedCertsStream = IOUtils.toInputStream(trustedCerts, StandardCharsets.UTF_8);
}
if (trustedCertsStream != null) {
trustedSslCerts = CryptoKeys.parseX509Certs(trustedCertsStream);
}
long jwkCacheDuration =
Long.parseLong((String) pluginConfig.getOrDefault(PARAM_JWK_CACHE_DURATION, "3600"));
JWTIssuerConfig.setHttpsJwksFactory(
new JWTIssuerConfig.HttpsJwksFactory(
jwkCacheDuration, DEFAULT_REFRESH_REPRIEVE_THRESHOLD, trustedSslCerts));
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 {
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))
.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.setTrustedCerts(trustedSslCerts);
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.setTrustedCerts(trustedSslCerts);
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(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws Exception {
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 jwt = parseAuthorizationHeader(header);
try {
String issuer = jwtConsumer.processToClaims(jwt).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()
: "";
}
}
} catch (InvalidJwtException ex) {
/* ignored */
}
}
switch (authResponse.getAuthCode()) {
case AUTHENTICATED:
final Principal principal = authResponse.getPrincipal();
request = wrapWithPrincipal(request, 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(request, 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 (algAllowlist != null)
jwtConsumerBuilder
.setJwsAlgorithmConstraints( // only allow the expected signature algorithm(s) in the
// given context
new AlgorithmConstraints(
AlgorithmConstraints.ConstraintType.PERMIT, algAllowlist.toArray(new String[0])));
jwtConsumerBuilder.setVerificationKeyResolver(verificationKeyResolver);
jwtConsumer = jwtConsumerBuilder.build(); // create the JwtConsumer instance
}
@Override
public void close() {
jwtConsumer = null;
}
@Override
public ValidatingJsonMap getSpec() {
final List<Api> apis = AnnotatedApi.getApis(new ModifyJWTAuthPluginConfigAPI());
return apis.get(0).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.getEncoder().encodeToString(headerJson.getBytes(StandardCharsets.UTF_8));
}
/** Response for authentication attempt */
protected static class JWTAuthenticationResponse {
private final Principal principal;
private String errorMessage;
private final 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);
}
}