You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@solr.apache.org by ja...@apache.org on 2023/09/03 16:33:19 UTC

[solr] branch branch_9x updated: SOLR-16897: Add support of OAuth 2.0/OIDC 'code with PKCE' flow (#1792) (#1889)

This is an automated email from the ASF dual-hosted git repository.

janhoy pushed a commit to branch branch_9x
in repository https://gitbox.apache.org/repos/asf/solr.git


The following commit(s) were added to refs/heads/branch_9x by this push:
     new 89393226019 SOLR-16897: Add support of OAuth 2.0/OIDC 'code with PKCE' flow (#1792) (#1889)
89393226019 is described below

commit 89393226019967c09d34b8043a53fccf22fb073a
Author: Jan Høydahl <ja...@users.noreply.github.com>
AuthorDate: Sun Sep 3 18:33:13 2023 +0200

    SOLR-16897: Add support of OAuth 2.0/OIDC 'code with PKCE' flow (#1792) (#1889)
    
    Co-authored-by: Lamine Idjeraoui <li...@apple.com>
    Co-authored-by: Jan Høydahl <ja...@apache.org>
    
    (cherry picked from commit 931de5157226655ec18b2530b4ac27905f0b5818)
---
 solr/CHANGES.txt                                   |   2 +
 .../apache/solr/servlet/LoadAdminUiServlet.java    |  37 +++++++-
 .../solr/servlet/LoadAdminUiServletTest.java       | 102 +++++++++++++++++++++
 .../apache/solr/security/jwt/JWTAuthPlugin.java    |  23 ++++-
 .../apache/solr/security/jwt/JWTIssuerConfig.java  |  59 ++++++++++++
 .../solr/security/jwt_plugin_jwk_security.json     |   2 +
 .../jwt_plugin_jwk_security_blockUnknownFalse.json |   2 +
 .../security/jwt/JWTAuthPluginIntegrationTest.java |   4 +
 .../solr/security/jwt/JWTAuthPluginTest.java       |  14 +++
 .../solr/security/jwt/JWTIssuerConfigTest.java     |  12 ++-
 .../pages/jwt-authentication-plugin.adoc           |   2 +
 11 files changed, 255 insertions(+), 4 deletions(-)

diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt
index 344927e4dbf..42044f59307 100644
--- a/solr/CHANGES.txt
+++ b/solr/CHANGES.txt
@@ -45,6 +45,8 @@ Improvements
 
 * SOLR-15474: Make Circuit breakers individually pluggable (Atri Sharma, Christine Poerschke, janhoy)
 
+* 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)
diff --git a/solr/core/src/java/org/apache/solr/servlet/LoadAdminUiServlet.java b/solr/core/src/java/org/apache/solr/servlet/LoadAdminUiServlet.java
index 5a7e797f297..9da4bc84775 100644
--- a/solr/core/src/java/org/apache/solr/servlet/LoadAdminUiServlet.java
+++ b/solr/core/src/java/org/apache/solr/servlet/LoadAdminUiServlet.java
@@ -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;
@@ -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 {
@@ -60,15 +66,20 @@ public final class LoadAdminUiServlet extends BaseSolrServlet {
       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 {
@@ -76,4 +87,26 @@ public final class LoadAdminUiServlet extends BaseSolrServlet {
       }
     }
   }
+
+  /**
+   * 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);
+  }
 }
diff --git a/solr/core/src/test/org/apache/solr/servlet/LoadAdminUiServletTest.java b/solr/core/src/test/org/apache/solr/servlet/LoadAdminUiServletTest.java
new file mode 100644
index 00000000000..299029f86f7
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/servlet/LoadAdminUiServletTest.java
@@ -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();
+  }
+}
diff --git a/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTAuthPlugin.java b/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTAuthPlugin.java
index ae20606f7e7..3e3f7578fc9 100644
--- a/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTAuthPlugin.java
+++ b/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTAuthPlugin.java
@@ -63,6 +63,7 @@ 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.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;
@@ -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;
@@ -280,10 +283,24 @@ public class JWTAuthPlugin extends AuthenticationPlugin
     }
 
     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
@@ -336,6 +353,8 @@ public class JWTAuthPlugin extends AuthenticationPlugin
               .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) {
@@ -847,9 +866,11 @@ public class JWTAuthPlugin extends AuthenticationPlugin
     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));
   }
diff --git a/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTIssuerConfig.java b/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTIssuerConfig.java
index 55ccbcc956a..947d040da8b 100644
--- a/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTIssuerConfig.java
+++ b/solr/modules/jwt-auth/src/java/org/apache/solr/security/jwt/JWTIssuerConfig.java
@@ -21,6 +21,7 @@ import com.google.common.annotations.VisibleForTesting;
 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;
@@ -32,8 +33,10 @@ import java.util.Collections;
 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;
@@ -41,9 +44,12 @@ 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";
@@ -51,7 +57,9 @@ public class JWTIssuerConfig {
   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;
@@ -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,
@@ -117,6 +131,10 @@ public class JWTIssuerConfig {
       if (authorizationEndpoint == null) {
         authorizationEndpoint = wellKnownDiscoveryConfig.getAuthorizationEndpoint();
       }
+
+      if (tokenEndpoint == null) {
+        tokenEndpoint = wellKnownDiscoveryConfig.getTokenEndpoint();
+      }
     }
     if (iss == null && usesHttpsJwk() && !JWTAuthPlugin.PRIMARY_ISSUER.equals(name)) {
       throw new SolrException(
@@ -141,6 +159,8 @@ public class JWTIssuerConfig {
     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);
@@ -150,6 +170,8 @@ public class JWTIssuerConfig {
     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(
@@ -315,6 +337,41 @@ public class JWTIssuerConfig {
     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);
@@ -324,6 +381,8 @@ public class JWTIssuerConfig {
     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());
     }
diff --git a/solr/modules/jwt-auth/src/test-files/solr/security/jwt_plugin_jwk_security.json b/solr/modules/jwt-auth/src/test-files/solr/security/jwt_plugin_jwk_security.json
index 772089e3819..313b409a230 100644
--- a/solr/modules/jwt-auth/src/test-files/solr/security/jwt_plugin_jwk_security.json
+++ b/solr/modules/jwt-auth/src/test-files/solr/security/jwt_plugin_jwk_security.json
@@ -13,6 +13,8 @@
     "realm": "my-solr-jwt",
     "adminUiScope": "solr:admin",
     "authorizationEndpoint": "http://acmepaymentscorp/oauth/auz/authorize",
+    "tokenEndpoint": "http://acmepaymentscorp/oauth/oauth20/token",
+    "authorizationFlow": "code_pkce",
     "clientId": "solr-cluster"
   }
 }
\ No newline at end of file
diff --git a/solr/modules/jwt-auth/src/test-files/solr/security/jwt_plugin_jwk_security_blockUnknownFalse.json b/solr/modules/jwt-auth/src/test-files/solr/security/jwt_plugin_jwk_security_blockUnknownFalse.json
index ef5bcdeea4a..e9dafff1c2f 100644
--- a/solr/modules/jwt-auth/src/test-files/solr/security/jwt_plugin_jwk_security_blockUnknownFalse.json
+++ b/solr/modules/jwt-auth/src/test-files/solr/security/jwt_plugin_jwk_security_blockUnknownFalse.json
@@ -13,6 +13,8 @@
     "realm": "my-solr-jwt-blockunknown-false",
     "adminUiScope": "solr:admin",
     "authorizationEndpoint": "http://acmepaymentscorp/oauth/auz/authorize",
+    "tokenEndpoint": "http://acmepaymentscorp/oauth/oauth20/token",
+    "authorizationFlow": "code_pkce",
     "clientId": "solr-cluster"
   },
   "authorization": {
diff --git a/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTAuthPluginIntegrationTest.java b/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTAuthPluginIntegrationTest.java
index 8ad48282665..34b009000af 100644
--- a/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTAuthPluginIntegrationTest.java
+++ b/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTAuthPluginIntegrationTest.java
@@ -162,6 +162,8 @@ public class JWTAuthPluginIntegrationTest extends SolrCloudAuthTestCase {
     String authData = new String(Base64.getDecoder().decode(headers.get("X-Solr-AuthData")), UTF_8);
     assertEquals(
         "{\n"
+            + "  \"tokenEndpoint\":\"http://acmepaymentscorp/oauth/oauth20/token\",\n"
+            + "  \"authorization_flow\":\"code_pkce\",\n"
             + "  \"scope\":\"solr:admin\",\n"
             + "  \"redirect_uris\":[],\n"
             + "  \"authorizationEndpoint\":\"http://acmepaymentscorp/oauth/auz/authorize\",\n"
@@ -184,6 +186,8 @@ public class JWTAuthPluginIntegrationTest extends SolrCloudAuthTestCase {
     String authData = new String(Base64.getDecoder().decode(headers.get("X-Solr-AuthData")), UTF_8);
     assertEquals(
         "{\n"
+            + "  \"tokenEndpoint\":\"http://acmepaymentscorp/oauth/oauth20/token\",\n"
+            + "  \"authorization_flow\":\"code_pkce\",\n"
             + "  \"scope\":\"solr:admin\",\n"
             + "  \"redirect_uris\":[],\n"
             + "  \"authorizationEndpoint\":\"http://acmepaymentscorp/oauth/auz/authorize\",\n"
diff --git a/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTAuthPluginTest.java b/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTAuthPluginTest.java
index f40ffee9be9..9e04865c6c3 100644
--- a/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTAuthPluginTest.java
+++ b/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTAuthPluginTest.java
@@ -45,6 +45,7 @@ import org.apache.solr.SolrTestCaseJ4;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.common.util.Utils;
 import org.apache.solr.security.VerifiedUserRoles;
+import org.apache.solr.servlet.LoadAdminUiServlet;
 import org.apache.solr.util.CryptoKeys;
 import org.jose4j.jwk.RsaJsonWebKey;
 import org.jose4j.jwk.RsaJwkGenerator;
@@ -519,6 +520,8 @@ public class JWTAuthPluginTest extends SolrTestCaseJ4 {
   public void xSolrAuthDataHeader() {
     testConfig.put("adminUiScope", "solr:admin");
     testConfig.put("authorizationEndpoint", "http://acmepaymentscorp/oauth/auz/authorize");
+    testConfig.put("tokenEndpoint", "http://acmepaymentscorp/oauth/oauth20/token");
+    testConfig.put("authorizationFlow", "code_pkce");
     testConfig.put("clientId", "solr-cluster");
     plugin.init(testConfig);
     String headerBase64 = plugin.generateAuthDataHeader();
@@ -528,6 +531,8 @@ public class JWTAuthPluginTest extends SolrTestCaseJ4 {
     assertEquals("solr:admin", parsed.get("scope"));
     assertEquals(
         "http://acmepaymentscorp/oauth/auz/authorize", parsed.get("authorizationEndpoint"));
+    assertEquals("http://acmepaymentscorp/oauth/oauth20/token", parsed.get("tokenEndpoint"));
+    assertEquals("code_pkce", parsed.get("authorization_flow"));
     assertEquals("solr-cluster", parsed.get("client_id"));
   }
 
@@ -703,4 +708,13 @@ public class JWTAuthPluginTest extends SolrTestCaseJ4 {
             .toString();
     assertEquals(2, plugin.parseCertsFromFile(pemFilePath).size());
   }
+
+  @Test
+  public void testRegisterTokenEndpointForCsp() {
+    testConfig.put("tokenEndpoint", "http://acmepaymentscorp/oauth/oauth20/token");
+    plugin.init(testConfig);
+    assertEquals(
+        "http://acmepaymentscorp/oauth/oauth20/token",
+        System.getProperty(LoadAdminUiServlet.SYSPROP_CSP_CONNECT_SRC_URLS));
+  }
 }
diff --git a/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTIssuerConfigTest.java b/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTIssuerConfigTest.java
index 3355486001a..57c0261b897 100644
--- a/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTIssuerConfigTest.java
+++ b/solr/modules/jwt-auth/src/test/org/apache/solr/security/jwt/JWTIssuerConfigTest.java
@@ -53,15 +53,19 @@ public class JWTIssuerConfigTest extends SolrTestCase {
             .setAud("audience")
             .setClientId("clientid")
             .setWellKnownUrl("wellknown")
-            .setAuthorizationEndpoint("https://issuer/authz");
+            .setAuthorizationEndpoint("https://issuer/authz")
+            .setTokenEndpoint("https://issuer/token")
+            .setAuthorizationFlow("code_pkce");
 
     testIssuerConfigMap = testIssuer.asConfig();
 
     testIssuerJson =
         "{\n"
             + "  \"aud\":\"audience\",\n"
+            + "  \"tokenEndpoint\":\"https://issuer/token\",\n"
             + "  \"wellKnownUrl\":\"wellknown\",\n"
             + "  \"clientId\":\"clientid\",\n"
+            + "  \"authorizationFlow\":\"code_pkce\",\n"
             + "  \"jwksUrl\":[\"https://issuer/path\"],\n"
             + "  \"name\":\"name\",\n"
             + "  \"iss\":\"issuer\",\n"
@@ -89,6 +93,11 @@ public class JWTIssuerConfigTest extends SolrTestCase {
     new JWTIssuerConfig(testIssuerConfigMap).isValid();
   }
 
+  @Test(expected = SolrException.class)
+  public void setInvalidAuthorizationFlow() {
+    new JWTIssuerConfig("name").setAuthorizationFlow("invalid_flow");
+  }
+
   @Test
   public void parseJwkSet() throws Exception {
     HashMap<String, Object> testJwks = new HashMap<>();
@@ -173,6 +182,7 @@ public class JWTIssuerConfigTest extends SolrTestCase {
     assertEquals("https://acmepaymentscorp/oauth/jwks", config.getJwksUrl());
     assertEquals("http://acmepaymentscorp", config.getIssuer());
     assertEquals("http://acmepaymentscorp/oauth/auz/authorize", config.getAuthorizationEndpoint());
+    assertEquals("http://acmepaymentscorp/oauth/oauth20/token", config.getTokenEndpoint());
     assertEquals(
         Arrays.asList(
             "READ", "WRITE", "DELETE", "openid", "scope", "profile", "email", "address", "phone"),
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/jwt-authentication-plugin.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/jwt-authentication-plugin.adoc
index ec79558548e..e4de26a87ad 100644
--- a/solr/solr-ref-guide/modules/deployment-guide/pages/jwt-authentication-plugin.adoc
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/jwt-authentication-plugin.adoc
@@ -85,6 +85,8 @@ jwk                  ; As an alternative to `jwksUrl` you may provide a static J
 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
+tokenEndpoint; The URL for the Id Provider's token endpoint ; Auto configured if `wellKnownUrl` is provided
+authorizationFlow; Specifies the OAuth 2.0 flow to be used. Supported flows are 'implicit' and 'code_pkce' (for authorization code with 'Proof Key for Code Exchange'). Note: 'implicit' is deprecated and it is highly recommended to use 'code_pkce' instead. ; implicit
 |===
 
 TIP: For backwards compatibility, all the configuration keys for the primary issuer may be configured as top-level keys, except `name`.