Skip to content

Commit

Permalink
SOLR-16897: Add support of OAuth 2.0/OIDC 'code with PKCE' flow
Browse files Browse the repository at this point in the history
Co-authored-by: Jan Høydahl <[email protected]>
  • Loading branch information
Lamine Idjeraoui and janhoy committed Sep 1, 2023
1 parent 9d6db8c commit 8264b15
Show file tree
Hide file tree
Showing 11 changed files with 255 additions and 4 deletions.
2 changes: 2 additions & 0 deletions solr/CHANGES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ Improvements

* SOLR-16927: Allow SolrClientCache clients to use Jetty HTTP2 clients (Alex Deparvu, David Smiley)

* SOLR-16896, SOLR-16897: Add support of OAuth 2.0/OIDC 'code with PKCE' flow (Lamine Idjeraoui, janhoy, Kevin Risden)

* SOLR-16879: Limit the number of concurrent expensive core admin operations by running them in a
dedicated thread pool. Backup, Restore and Split are expensive operations.
(Pierre Salagnac, David Smiley)
Expand Down
37 changes: 35 additions & 2 deletions solr/core/src/java/org/apache/solr/servlet/LoadAdminUiServlet.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@
*/
package org.apache.solr.servlet;

import com.google.common.net.HttpHeaders;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.io.output.CloseShieldOutputStream;
Expand All @@ -37,6 +41,8 @@ public final class LoadAdminUiServlet extends BaseSolrServlet {
// check system properties for whether or not admin UI is disabled, default is false
private static final boolean disabled =
Boolean.parseBoolean(System.getProperty("disableAdminUI", "false"));
// list of comma separated URLs to inject into the CSP connect-src directive
public static final String SYSPROP_CSP_CONNECT_SRC_URLS = "solr.ui.headers.csp.connect-src.urls";

@Override
public void doGet(HttpServletRequest _request, HttpServletResponse _response) throws IOException {
Expand All @@ -60,20 +66,47 @@ public void doGet(HttpServletRequest _request, HttpServletResponse _response) th
if (in != null && cores != null) {
response.setCharacterEncoding("UTF-8");
response.setContentType("text/html");
String connectSrc = generateCspConnectSrc();
response.setHeader(
HttpHeaders.CONTENT_SECURITY_POLICY,
"default-src 'none'; base-uri 'none'; connect-src "
+ connectSrc
+ "; form-action 'self'; font-src 'self'; frame-ancestors 'none'; img-src 'self' data:; media-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self';");

// We have to close this to flush OutputStreamWriter buffer
try (Writer out =
new OutputStreamWriter(
CloseShieldOutputStream.wrap(response.getOutputStream()), StandardCharsets.UTF_8)) {
Package pack = SolrCore.class.getPackage();
String html =
new String(in.readAllBytes(), StandardCharsets.UTF_8)
.replace("${version}", pack.getSpecificationVersion());
.replace("${version}", getSolrCorePackageSpecVersion());
out.write(html);
}
} else {
response.sendError(404);
}
}
}

/**
* Retrieves the specification version of the SolrCore package.
*
* @return The specification version of the SolrCore class's package or Unknown if it's
* unavailable.
*/
private String getSolrCorePackageSpecVersion() {
Package pack = SolrCore.class.getPackage();
return pack.getSpecificationVersion() != null ? pack.getSpecificationVersion() : "Unknown";
}

/**
* Fetch the value of {@link #SYSPROP_CSP_CONNECT_SRC_URLS} system property, split by comma, and
* concatenate them into a space-separated string that can be used in CSP connect-src directive
*/
private String generateCspConnectSrc() {
String cspURLs = System.getProperty(SYSPROP_CSP_CONNECT_SRC_URLS, "");
List<String> props = new ArrayList<>(Arrays.asList(cspURLs.split(",")));
props.add("'self'");
return String.join(" ", props);
}
}
102 changes: 102 additions & 0 deletions solr/core/src/test/org/apache/solr/servlet/LoadAdminUiServletTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* 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.servlet;

import static org.apache.solr.servlet.LoadAdminUiServlet.SYSPROP_CSP_CONNECT_SRC_URLS;
import static org.mockito.Mockito.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Set;
import javax.servlet.ServletConfig;
import javax.servlet.ServletContext;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.solr.SolrTestCaseJ4;
import org.apache.solr.core.CoreContainer;
import org.junit.After;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

public class LoadAdminUiServletTest extends SolrTestCaseJ4 {

@InjectMocks private LoadAdminUiServlet servlet;
@Mock private HttpServletRequest mockRequest;
@Mock private HttpServletResponse mockResponse;
@Mock private CoreContainer coreContainer;
@Mock private ServletConfig servletConfig;
@Mock private ServletContext mockServletContext;
@Mock private ServletOutputStream mockOutputStream;

private static final Set<String> CSP_URLS =
Set.of(
"http://example1.com/token",
"https://example2.com/path/uri1",
"http://example3.com/oauth2/uri2");

@Override
@Before
public void setUp() throws Exception {
super.setUp();
MockitoAnnotations.openMocks(this);
when(mockRequest.getRequestURI()).thenReturn("/path/URI");
when(mockRequest.getContextPath()).thenReturn("/path");
when(mockRequest.getAttribute("org.apache.solr.CoreContainer")).thenReturn(coreContainer);
when(servletConfig.getServletContext()).thenReturn(mockServletContext);
when(mockResponse.getOutputStream()).thenReturn(mockOutputStream);
InputStream mockInputStream =
new ByteArrayInputStream("mock content".getBytes(StandardCharsets.UTF_8));
when(mockServletContext.getResourceAsStream(anyString())).thenReturn(mockInputStream);
}

@BeforeClass
public static void ensureWorkingMockito() {
assumeWorkingMockito();
}

@Test
public void testDefaultCSPHeaderSet() throws IOException {
System.setProperty(SYSPROP_CSP_CONNECT_SRC_URLS, String.join(",", CSP_URLS));
ArgumentCaptor<String> headerNameCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> headerValueCaptor = ArgumentCaptor.forClass(String.class);
servlet.doGet(mockRequest, mockResponse);

verify(mockResponse).setHeader(headerNameCaptor.capture(), headerValueCaptor.capture());
assertEquals("Content-Security-Policy", headerNameCaptor.getValue());
String cspValue = headerValueCaptor.getValue();
for (String endpoint : CSP_URLS) {
assertTrue("Expected CSP value to contain " + endpoint, cspValue.contains(endpoint));
}
}

@Override
@After
public void tearDown() throws Exception {
super.tearDown();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
import org.apache.solr.security.ConfigEditablePlugin;
import org.apache.solr.security.jwt.JWTAuthPlugin.JWTAuthenticationResponse.AuthCode;
import org.apache.solr.security.jwt.api.ModifyJWTAuthPluginConfigAPI;
import org.apache.solr.servlet.LoadAdminUiServlet;
import org.apache.solr.util.CryptoKeys;
import org.eclipse.jetty.client.api.Request;
import org.jose4j.jwa.AlgorithmConstraints;
Expand Down Expand Up @@ -130,7 +131,9 @@ public class JWTAuthPlugin extends AuthenticationPlugin
JWTIssuerConfig.PARAM_CLIENT_ID,
JWTIssuerConfig.PARAM_WELL_KNOWN_URL,
JWTIssuerConfig.PARAM_AUDIENCE,
JWTIssuerConfig.PARAM_AUTHORIZATION_ENDPOINT);
JWTIssuerConfig.PARAM_AUTHORIZATION_ENDPOINT,
JWTIssuerConfig.PARAM_TOKEN_ENDPOINT,
JWTIssuerConfig.PARAM_AUTHORIZATION_FLOW);

private JwtConsumer jwtConsumer;
private boolean requireExpirationTime;
Expand Down Expand Up @@ -280,10 +283,24 @@ public void init(Map<String, Object> pluginConfig) {
}

initConsumer();
registerTokenEndpointForCsp();

lastInitTime = Instant.now();
}

/**
* Record Issuer token URL as a system property so it can be picked up and sent to Admin UI as CSP
*/
protected void registerTokenEndpointForCsp() {
final String syspropName = LoadAdminUiServlet.SYSPROP_CSP_CONNECT_SRC_URLS;
String url = !issuerConfigs.isEmpty() ? getPrimaryIssuer().getTokenEndpoint() : null;
if (url != null) {
System.setProperty(syspropName, url);
} else {
System.clearProperty(syspropName);
}
}

/**
* Given a configuration object of a file name or list of file names, read X509 certificates from
* each file
Expand Down Expand Up @@ -336,6 +353,8 @@ private Optional<JWTIssuerConfig> parseIssuerFromTopLevelConfig(Map<String, Obje
.setJwksUrl(conf.get(JWTIssuerConfig.PARAM_JWKS_URL))
.setAuthorizationEndpoint(
(String) conf.get(JWTIssuerConfig.PARAM_AUTHORIZATION_ENDPOINT))
.setTokenEndpoint((String) conf.get(JWTIssuerConfig.PARAM_TOKEN_ENDPOINT))
.setAuthorizationFlow((String) conf.get(JWTIssuerConfig.PARAM_AUTHORIZATION_FLOW))
.setClientId((String) conf.get(JWTIssuerConfig.PARAM_CLIENT_ID))
.setWellKnownUrl((String) conf.get(JWTIssuerConfig.PARAM_WELL_KNOWN_URL));
if (conf.get(JWTIssuerConfig.PARAM_JWK) != null) {
Expand Down Expand Up @@ -847,9 +866,11 @@ protected String generateAuthDataHeader() {
Map<String, Object> data = new HashMap<>();
data.put(
JWTIssuerConfig.PARAM_AUTHORIZATION_ENDPOINT, primaryIssuer.getAuthorizationEndpoint());
data.put(JWTIssuerConfig.PARAM_TOKEN_ENDPOINT, primaryIssuer.getTokenEndpoint());
data.put("client_id", primaryIssuer.getClientId());
data.put("scope", adminUiScope);
data.put("redirect_uris", redirectUris);
data.put("authorization_flow", primaryIssuer.getAuthorizationFlow());
String headerJson = Utils.toJSONString(data);
return Base64.getEncoder().encodeToString(headerJson.getBytes(StandardCharsets.UTF_8));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
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;
Expand All @@ -32,26 +33,33 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.solr.common.SolrException;
import org.apache.solr.common.util.StrUtils;
import org.apache.solr.common.util.Utils;
import org.jose4j.http.Get;
import org.jose4j.http.SimpleResponse;
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";
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_TOKEN_ENDPOINT = "tokenEndpoint";
static final String PARAM_CLIENT_ID = "clientId";
static final String PARAM_AUTHORIZATION_FLOW = "authorizationFlow";

private static HttpsJwksFactory httpsJwksFactory = new HttpsJwksFactory(3600, 5000);
private String iss;
Expand All @@ -64,12 +72,18 @@ public class JWTIssuerConfig {
private WellKnownDiscoveryConfig wellKnownDiscoveryConfig;
private String clientId;
private String authorizationEndpoint;
private String tokenEndpoint;
private String authorizationFlow;
private Collection<X509Certificate> trustedCerts;

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.";
private static final String DEFAULT_AUTHORIZATION_FLOW =
"implicit"; // 'implicit' to be deprecated
private static final Set<String> VALID_AUTHORIZATION_FLOWS =
Set.of(DEFAULT_AUTHORIZATION_FLOW, "code_pkce");

/**
* Create config for further configuration with setters, builder style. Once all values are set,
Expand Down Expand Up @@ -117,6 +131,10 @@ public void init() {
if (authorizationEndpoint == null) {
authorizationEndpoint = wellKnownDiscoveryConfig.getAuthorizationEndpoint();
}

if (tokenEndpoint == null) {
tokenEndpoint = wellKnownDiscoveryConfig.getTokenEndpoint();
}
}
if (iss == null && usesHttpsJwk() && !JWTAuthPlugin.PRIMARY_ISSUER.equals(name)) {
throw new SolrException(
Expand All @@ -141,6 +159,8 @@ protected void parseConfigMap(Map<String, Object> configMap) {
setJwksUrl(confJwksUrl);
setJsonWebKeySet(conf.get(PARAM_JWK));
setAuthorizationEndpoint((String) conf.get(PARAM_AUTHORIZATION_ENDPOINT));
setTokenEndpoint((String) conf.get(PARAM_TOKEN_ENDPOINT));
setAuthorizationFlow((String) conf.get(PARAM_AUTHORIZATION_FLOW));

conf.remove(PARAM_WELL_KNOWN_URL);
conf.remove(PARAM_ISSUER);
Expand All @@ -150,6 +170,8 @@ protected void parseConfigMap(Map<String, Object> configMap) {
conf.remove(PARAM_JWKS_URL);
conf.remove(PARAM_JWK);
conf.remove(PARAM_AUTHORIZATION_ENDPOINT);
conf.remove(PARAM_TOKEN_ENDPOINT);
conf.remove(PARAM_AUTHORIZATION_FLOW);

if (!conf.isEmpty()) {
throw new SolrException(
Expand Down Expand Up @@ -315,6 +337,41 @@ public JWTIssuerConfig setAuthorizationEndpoint(String authorizationEndpoint) {
return this;
}

public String getTokenEndpoint() {
return tokenEndpoint;
}

public JWTIssuerConfig setTokenEndpoint(String tokenEndpoint) {
this.tokenEndpoint = tokenEndpoint;
return this;
}

public String getAuthorizationFlow() {
return authorizationFlow;
}

public JWTIssuerConfig setAuthorizationFlow(String authorizationFlow) {
this.authorizationFlow =
StrUtils.isNullOrEmpty(authorizationFlow)
? DEFAULT_AUTHORIZATION_FLOW
: authorizationFlow.trim();
if (!VALID_AUTHORIZATION_FLOWS.contains(this.authorizationFlow)) {
throw new SolrException(
SolrException.ErrorCode.SERVER_ERROR,
"Invalid value for "
+ PARAM_AUTHORIZATION_FLOW
+ ". Expected one of "
+ VALID_AUTHORIZATION_FLOWS
+ " but found "
+ authorizationFlow);
}
if (this.authorizationFlow.equals("implicit")) {
log.warn(
"JWT authentication plugin is using 'implicit flow' which is deprecated and less secure. It's recommended to switch to 'code_pkce'");
}
return this;
}

public Map<String, Object> asConfig() {
HashMap<String, Object> config = new HashMap<>();
putIfNotNull(config, PARAM_ISS_NAME, name);
Expand All @@ -324,6 +381,8 @@ public Map<String, Object> asConfig() {
putIfNotNull(config, PARAM_WELL_KNOWN_URL, wellKnownUrl);
putIfNotNull(config, PARAM_CLIENT_ID, clientId);
putIfNotNull(config, PARAM_AUTHORIZATION_ENDPOINT, authorizationEndpoint);
putIfNotNull(config, PARAM_TOKEN_ENDPOINT, tokenEndpoint);
putIfNotNull(config, PARAM_AUTHORIZATION_FLOW, authorizationFlow);
if (jsonWebKeySet != null) {
putIfNotNull(config, PARAM_JWK, jsonWebKeySet.getJsonWebKeys());
}
Expand Down
Loading

0 comments on commit 8264b15

Please sign in to comment.