blob: 7f53f4da3115b65282dffdd3023dac05580f1c3c [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 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 // Remove this option at some point
static final String PARAM_JWK_URL = "jwkUrl";
static final String PARAM_JWKS_URL = "jwksUrl";
static final String PARAM_JWK = "jwk";
static final String PARAM_ISSUER = "iss";
static final String PARAM_AUDIENCE = "aud";
static final String PARAM_WELL_KNOWN_URL = "wellKnownUrl";
static final String PARAM_AUTHORIZATION_ENDPOINT = "authorizationEndpoint";
static final String PARAM_CLIENT_ID = "clientId";
private static HttpsJwksFactory httpsJwksFactory =
new HttpsJwksFactory(3600, 5000);
private String iss;
private String aud;
private JsonWebKeySet jsonWebKeySet;
private String name;
private List<String> jwksUrl;
private List<HttpsJwks> httpsJwks;
private String wellKnownUrl;
private WellKnownDiscoveryConfig wellKnownDiscoveryConfig;
private String clientId;
private String authorizationEndpoint;
public static boolean ALLOW_OUTBOUND_HTTP = Boolean.parseBoolean(System.getProperty("solr.auth.jwt.allowOutboundHttp", "false"));
public static final String ALLOW_OUTBOUND_HTTP_ERR_MSG = "HTTPS required for IDP communication. Please use SSL or start your nodes with -Dsolr.auth.jwt.allowOutboundHttp=true to allow HTTP for test purposes.";
/**
* Create config for further configuration with setters, builder style.
* Once all values are set, call {@link #init()} before further use
*
* @param name a unique name for this issuer
*/
public JWTIssuerConfig(String name) {
this.name = name;
}
/**
* Initialize issuer config from a generic configuration map
*
* @param configMap map of configuration keys anv values
*/
public JWTIssuerConfig(Map<String, Object> configMap) {
parseConfigMap(configMap);
}
/**
* Call this to validate and initialize an object which is populated with setters.
* Init will fetch wellKnownUrl if relevant
* @throws SolrException if issuer is missing
*/
public void init() {
if (!isValid()) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Configuration is not valid");
}
if (wellKnownUrl != null) {
wellKnownDiscoveryConfig = fetchWellKnown(wellKnownUrl);
if (iss == null) {
iss = wellKnownDiscoveryConfig.getIssuer();
}
if (jwksUrl == null) {
jwksUrl = Collections.singletonList(wellKnownDiscoveryConfig.getJwksUrl());
}
if (authorizationEndpoint == null) {
authorizationEndpoint = wellKnownDiscoveryConfig.getAuthorizationEndpoint();
}
}
if (iss == null && usesHttpsJwk() && !JWTAuthPlugin.PRIMARY_ISSUER.equals(name)) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Missing required config 'iss' for issuer " + getName());
}
}
/**
* Parses configuration for one IssuerConfig and sets all variables found
* @throws SolrException if unknown parameter names found in config
*/
protected void parseConfigMap(Map<String, Object> configMap) {
HashMap<String, Object> conf = new HashMap<>(configMap); // Clone
setName((String) conf.get(PARAM_ISS_NAME));
setWellKnownUrl((String) conf.get(PARAM_WELL_KNOWN_URL));
setIss((String) conf.get(PARAM_ISSUER));
setClientId((String) conf.get(PARAM_CLIENT_ID));
setAud((String) conf.get(PARAM_AUDIENCE));
if (conf.get(PARAM_JWK_URL) != null) {
log.warn("Configuration uses deprecated key {}. Please use {} instead", PARAM_JWK_URL, PARAM_JWKS_URL);
}
Object confJwksUrl = conf.get(PARAM_JWKS_URL) != null ? conf.get(PARAM_JWKS_URL) : conf.get(PARAM_JWK_URL);
setJwksUrl(confJwksUrl);
setJsonWebKeySet(conf.get(PARAM_JWK));
setAuthorizationEndpoint((String) conf.get(PARAM_AUTHORIZATION_ENDPOINT));
conf.remove(PARAM_WELL_KNOWN_URL);
conf.remove(PARAM_ISSUER);
conf.remove(PARAM_ISS_NAME);
conf.remove(PARAM_CLIENT_ID);
conf.remove(PARAM_AUDIENCE);
conf.remove(PARAM_JWKS_URL);
conf.remove(PARAM_JWK_URL);
conf.remove(PARAM_JWK);
conf.remove(PARAM_AUTHORIZATION_ENDPOINT);
if (!conf.isEmpty()) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Unknown configuration key " + conf.keySet() + " for issuer " + name);
}
}
/**
* Setter that takes a jwk config object, parses it into a {@link JsonWebKeySet} and sets it
* @param jwksObject the config object to parse
*/
@SuppressWarnings("unchecked")
protected void setJsonWebKeySet(Object jwksObject) {
try {
if (jwksObject != null) {
jsonWebKeySet = parseJwkSet((Map<String, Object>) jwksObject);
}
} catch (JoseException e) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Failed parsing parameter 'jwk' for issuer " + getName(), e);
}
}
@SuppressWarnings("unchecked")
protected static JsonWebKeySet parseJwkSet(Map<String, Object> jwkObj) throws JoseException {
JsonWebKeySet webKeySet = new JsonWebKeySet();
if (jwkObj.containsKey("keys")) {
List<Object> jwkList = (List<Object>) jwkObj.get("keys");
for (Object jwkO : jwkList) {
webKeySet.addJsonWebKey(JsonWebKey.Factory.newJwk((Map<String, Object>) jwkO));
}
} else {
webKeySet = new JsonWebKeySet(JsonWebKey.Factory.newJwk(jwkObj));
}
return webKeySet;
}
private WellKnownDiscoveryConfig fetchWellKnown(String wellKnownUrl) {
return WellKnownDiscoveryConfig.parse(wellKnownUrl);
}
public String getIss() {
return iss;
}
public JWTIssuerConfig setIss(String iss) {
this.iss = iss;
return this;
}
public String getName() {
return name;
}
public JWTIssuerConfig setName(String name) {
this.name = name;
return this;
}
public String getWellKnownUrl() {
return wellKnownUrl;
}
public JWTIssuerConfig setWellKnownUrl(String wellKnownUrl) {
this.wellKnownUrl = wellKnownUrl;
return this;
}
public List<String> getJwksUrls() {
return jwksUrl;
}
public JWTIssuerConfig setJwksUrl(List<String> jwksUrl) {
this.jwksUrl = jwksUrl;
return this;
}
/**
* Setter that converts from String or List into a list
* @param jwksUrlListOrString object that should be either string or list
* @return this for builder pattern
* @throws SolrException if wrong type
*/
@SuppressWarnings("unchecked")
public JWTIssuerConfig setJwksUrl(Object jwksUrlListOrString) {
if (jwksUrlListOrString instanceof String)
this.jwksUrl = Collections.singletonList((String) jwksUrlListOrString);
else if (jwksUrlListOrString instanceof List)
this.jwksUrl = (List<String>) jwksUrlListOrString;
else if (jwksUrlListOrString != null)
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Parameter " + PARAM_JWKS_URL + " must be either List or String");
return this;
}
public List<HttpsJwks> getHttpsJwks() {
if (httpsJwks == null) {
httpsJwks = httpsJwksFactory.createList(getJwksUrls());
}
return httpsJwks;
}
/**
* Set the factory to use when creating HttpsJwks objects
* @param httpsJwksFactory factory with custom settings
*/
public static void setHttpsJwksFactory(HttpsJwksFactory httpsJwksFactory) {
JWTIssuerConfig.httpsJwksFactory = httpsJwksFactory;
}
public JsonWebKeySet getJsonWebKeySet() {
return jsonWebKeySet;
}
public JWTIssuerConfig setJsonWebKeySet(JsonWebKeySet jsonWebKeySet) {
this.jsonWebKeySet = jsonWebKeySet;
return this;
}
/**
* Check if the issuer is backed by HttpsJwk url(s)
* @return true if keys are fetched over https
*/
public boolean usesHttpsJwk() {
return getJwksUrls() != null && !getJwksUrls().isEmpty();
}
public WellKnownDiscoveryConfig getWellKnownDiscoveryConfig() {
return wellKnownDiscoveryConfig;
}
public String getAud() {
return aud;
}
public JWTIssuerConfig setAud(String aud) {
this.aud = aud;
return this;
}
public String getClientId() {
return clientId;
}
public JWTIssuerConfig setClientId(String clientId) {
this.clientId = clientId;
return this;
}
public String getAuthorizationEndpoint() {
return authorizationEndpoint;
}
public JWTIssuerConfig setAuthorizationEndpoint(String authorizationEndpoint) {
this.authorizationEndpoint = authorizationEndpoint;
return this;
}
public Map<String,Object> asConfig() {
HashMap<String,Object> config = new HashMap<>();
putIfNotNull(config, PARAM_ISS_NAME, name);
putIfNotNull(config, PARAM_ISSUER, iss);
putIfNotNull(config, PARAM_AUDIENCE, aud);
putIfNotNull(config, PARAM_JWKS_URL, jwksUrl);
putIfNotNull(config, PARAM_WELL_KNOWN_URL, wellKnownUrl);
putIfNotNull(config, PARAM_CLIENT_ID, clientId);
putIfNotNull(config, PARAM_AUTHORIZATION_ENDPOINT, authorizationEndpoint);
if (jsonWebKeySet != null) {
putIfNotNull(config, PARAM_JWK, jsonWebKeySet.getJsonWebKeys());
}
return config;
}
private void putIfNotNull(HashMap<String, Object> config, String paramName, Object value) {
if (value != null) {
config.put(paramName, value);
}
}
/**
* Validates that this config has a name and either jwksUrl, wellkKownUrl or jwk
* @return true if a configuration is found and is valid, otherwise false
* @throws SolrException if configuration is present but wrong
*/
public boolean isValid() {
int jwkConfigured = wellKnownUrl != null ? 1 : 0;
jwkConfigured += jwksUrl != null ? 2 : 0;
jwkConfigured += jsonWebKeySet != null ? 2 : 0;
if (jwkConfigured > 3) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "JWTAuthPlugin needs to configure exactly one of " +
PARAM_WELL_KNOWN_URL + ", " + PARAM_JWKS_URL + " and " + PARAM_JWK);
}
if (jwkConfigured > 0 && name == null) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR,
"Parameter 'name' is required for issuer configurations");
}
return jwkConfigured > 0;
}
/**
*
*/
static class HttpsJwksFactory {
private final long jwkCacheDuration;
private final long refreshReprieveThreshold;
public HttpsJwksFactory(long jwkCacheDuration, long refreshReprieveThreshold) {
this.jwkCacheDuration = jwkCacheDuration;
this.refreshReprieveThreshold = refreshReprieveThreshold;
}
/**
* While the class name is HttpsJwks, it actually works with plain http formatted url as well.
* @param url the Url to connect to for JWK details.
*/
private HttpsJwks create(String url) {
try {
URL jwksUrl = new URL(url);
checkAllowOutboundHttpConnections(PARAM_JWKS_URL, jwksUrl);
} catch (MalformedURLException e) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Url " + url + " configured in " + PARAM_JWKS_URL + " is not a valid URL");
}
HttpsJwks httpsJkws = new HttpsJwks(url);
httpsJkws.setDefaultCacheDuration(jwkCacheDuration);
httpsJkws.setRefreshReprieveThreshold(refreshReprieveThreshold);
return httpsJkws;
}
public List<HttpsJwks> createList(List<String> jwkUrls) {
return jwkUrls.stream().map(this::create).collect(Collectors.toList());
}
}
/**
* Config object for a OpenId Connect well-known config
* Typically exposed through /.well-known/openid-configuration endpoint
*/
public static class WellKnownDiscoveryConfig {
private Map<String, Object> securityConf;
WellKnownDiscoveryConfig(Map<String, Object> securityConf) {
this.securityConf = securityConf;
}
public static WellKnownDiscoveryConfig parse(String urlString) {
try {
URL url = new URL(urlString);
if (!Arrays.asList("https", "file", "http").contains(url.getProtocol())) {
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Well-known config URL must be one of HTTPS or HTTP or file");
}
checkAllowOutboundHttpConnections(PARAM_WELL_KNOWN_URL, url);
return parse(url.openStream());
} catch (MalformedURLException e) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Well-known config URL " + urlString + " is malformed", e);
} catch (IOException e) {
throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Well-known config could not be read from url " + urlString, e);
}
}
public static WellKnownDiscoveryConfig parse(String json, Charset charset) {
return parse(new ByteArrayInputStream(json.getBytes(charset)));
}
@SuppressWarnings("unchecked")
public static WellKnownDiscoveryConfig parse(InputStream configStream) {
return new WellKnownDiscoveryConfig((Map<String, Object>) Utils.fromJSON(configStream));
}
public String getJwksUrl() {
return (String) securityConf.get("jwks_uri");
}
public String getIssuer() {
return (String) securityConf.get("issuer");
}
public String getAuthorizationEndpoint() {
return (String) securityConf.get("authorization_endpoint");
}
public String getUserInfoEndpoint() {
return (String) securityConf.get("userinfo_endpoint");
}
public String getTokenEndpoint() {
return (String) securityConf.get("token_endpoint");
}
@SuppressWarnings("unchecked")
public List<String> getScopesSupported() {
return (List<String>) securityConf.get("scopes_supported");
}
@SuppressWarnings("unchecked")
public List<String> getResponseTypesSupported() {
return (List<String>) securityConf.get("response_types_supported");
}
}
public static void checkAllowOutboundHttpConnections(String parameterName, URL url) {
if ("http".equalsIgnoreCase(url.getProtocol())) {
if (!ALLOW_OUTBOUND_HTTP) {
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, parameterName + " is using http protocol. " + ALLOW_OUTBOUND_HTTP_ERR_MSG);
}
}
}
}