This is an automated email from the ASF dual-hosted git repository.
bonampak pushed a commit to branch v2.1.x
in repository https://gitbox.apache.org/repos/asf/knox.git
The following commit(s) were added to refs/heads/v2.1.x by this push:
new 4e9284b3f KNOX-3258: Fix OIDC client login (#1224)
4e9284b3f is described below
commit 4e9284b3fc241e756211b6b4f920059d602128ba
Author: bonampak <[email protected]>
AuthorDate: Wed May 20 13:38:46 2026 +0200
KNOX-3258: Fix OIDC client login (#1224)
* KNOX-3258: add integration test with testcontainers-keycloak for pac4j
oidc client login.
* KNOX-3258: upgrade testcontainers to latest version for Java 8 (1.16.3)
and use keycloak 21.1.2.
* KNOX-3258: Fix OIDC login by upgrading com.nimbusds:oauth2-oidc-sdk to
9.13.
---
gateway-provider-security-pac4j/pom.xml | 31 ++
.../knox/gateway/pac4j/MockHttpServletRequest.java | 15 +
.../gateway/pac4j/Pac4jProviderOidcClientTest.java | 427 +++++++++++++++++++++
.../src/test/resources/keycloak/realm-export.json | 189 +++++++++
.../src/test/resources/log4j2-test.xml | 29 ++
pom.xml | 21 +-
6 files changed, 711 insertions(+), 1 deletion(-)
diff --git a/gateway-provider-security-pac4j/pom.xml
b/gateway-provider-security-pac4j/pom.xml
index 24316bdd1..185407554 100644
--- a/gateway-provider-security-pac4j/pom.xml
+++ b/gateway-provider-security-pac4j/pom.xml
@@ -106,6 +106,10 @@
<groupId>org.pac4j</groupId>
<artifactId>pac4j-oidc</artifactId>
</dependency>
+ <dependency>
+ <groupId>com.nimbusds</groupId>
+ <artifactId>oauth2-oidc-sdk</artifactId>
+ </dependency>
<dependency>
<groupId>org.pac4j</groupId>
<artifactId>pac4j-saml-opensamlv3</artifactId>
@@ -153,6 +157,33 @@
<artifactId>spring-core</artifactId>
</dependency>
+ <dependency>
+ <groupId>org.jsoup</groupId>
+ <artifactId>jsoup</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.httpcomponents</groupId>
+ <artifactId>httpclient</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.httpcomponents</groupId>
+ <artifactId>httpcore</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.testcontainers</groupId>
+ <artifactId>testcontainers</artifactId>
+ <scope>test</scope>
+ <exclusions>
+ <exclusion>
+ <artifactId>annotations</artifactId>
+ <groupId>org.jetbrains</groupId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+
<dependency>
<groupId>org.apache.knox</groupId>
<artifactId>gateway-test-utils</artifactId>
diff --git
a/gateway-provider-security-pac4j/src/test/java/org/apache/knox/gateway/pac4j/MockHttpServletRequest.java
b/gateway-provider-security-pac4j/src/test/java/org/apache/knox/gateway/pac4j/MockHttpServletRequest.java
index 26e6df3af..6014efc30 100644
---
a/gateway-provider-security-pac4j/src/test/java/org/apache/knox/gateway/pac4j/MockHttpServletRequest.java
+++
b/gateway-provider-security-pac4j/src/test/java/org/apache/knox/gateway/pac4j/MockHttpServletRequest.java
@@ -106,4 +106,19 @@ public class MockHttpServletRequest extends
HttpServletRequestWrapper {
public Object getAttribute(String name) {
return attributes.get(name);
}
+
+ @Override
+ public Map<String, String[]> getParameterMap() {
+ if (parameters == null) {
+ return java.util.Collections.emptyMap();
+ }
+
+ Map<String, String[]> map = new java.util.HashMap<>();
+ for (Map.Entry<String, String> entry : parameters.entrySet()) {
+ map.put(entry.getKey(), new String[]{ entry.getValue() });
+ }
+
+ // The Servlet API specifies that this map is generally immutable
+ return java.util.Collections.unmodifiableMap(map);
+ }
}
diff --git
a/gateway-provider-security-pac4j/src/test/java/org/apache/knox/gateway/pac4j/Pac4jProviderOidcClientTest.java
b/gateway-provider-security-pac4j/src/test/java/org/apache/knox/gateway/pac4j/Pac4jProviderOidcClientTest.java
new file mode 100644
index 000000000..dec64be76
--- /dev/null
+++
b/gateway-provider-security-pac4j/src/test/java/org/apache/knox/gateway/pac4j/Pac4jProviderOidcClientTest.java
@@ -0,0 +1,427 @@
+/*
+ * 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.knox.gateway.pac4j;
+
+import org.apache.http.Header;
+import org.apache.http.NameValuePair;
+import org.apache.http.client.entity.UrlEncodedFormEntity;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.utils.URLEncodedUtils;
+import org.apache.http.impl.client.BasicCookieStore;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.impl.cookie.BasicClientCookie;
+import org.apache.http.message.BasicNameValuePair;
+import org.apache.http.util.EntityUtils;
+import org.apache.knox.gateway.audit.api.AuditContext;
+import org.apache.knox.gateway.audit.api.AuditService;
+import org.apache.knox.gateway.audit.api.Auditor;
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.pac4j.filter.Pac4jDispatcherFilter;
+import org.apache.knox.gateway.pac4j.filter.Pac4jIdentityAdapter;
+import org.apache.knox.gateway.pac4j.session.KnoxSessionStore;
+import org.apache.knox.gateway.services.GatewayServices;
+import org.apache.knox.gateway.services.ServiceType;
+import org.apache.knox.gateway.services.security.AliasService;
+import org.apache.knox.gateway.services.security.impl.DefaultCryptoService;
+import org.apache.knox.test.category.VerifyTest;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.easymock.EasyMock;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.pac4j.core.util.Pac4jConstants;
+import org.pac4j.oidc.client.OidcClient;
+import org.testcontainers.containers.GenericContainer;
+
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletException;
+import javax.servlet.http.Cookie;
+import java.net.URI;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assume.assumeNoException;
+
+@Category(VerifyTest.class)
+public class Pac4jProviderOidcClientTest {
+
+ private static final Logger LOGGER =
LogManager.getLogger(Pac4jProviderOidcClientTest.class);
+ private static final String LOCALHOST = "localhost";
+ private static final String HADOOP_SERVICE_URL = "https://" + LOCALHOST +
":8443/gateway/sandbox/webhdfs/v1/tmp?op=LISTSTATUS";
+ private static final String KNOXSSO_SERVICE_URL = "https://" + LOCALHOST +
":8443/gateway/idp/api/v1/websso";
+ private static final String PAC4J_CALLBACK_URL = KNOXSSO_SERVICE_URL;
+ private static final String ORIGINAL_URL = "originalUrl";
+ private static final String CLUSTER_NAME = "knox";
+ private static final String PAC4J_PASSWORD = "pwdfortest";
+ private static final String CLIENT_CLASS =
OidcClient.class.getSimpleName();
+ private static final String USERNAME = "JaneUser";
+ public static final String KEYCLOAK_IMAGE = "quay.io/keycloak/keycloak";
+ public static final String KEYCLOAK_VERSION = "21.1.2";
+
+ private static GenericContainer<?> keycloakContainer;
+
+ @BeforeClass
+ public static void setUpClass() {
+ try {
+ keycloakContainer = new GenericContainer<>(KEYCLOAK_IMAGE + ":"+
KEYCLOAK_VERSION)
+ .withExposedPorts(8080)
+ .withEnv("KEYCLOAK_ADMIN", "admin")
+ .withEnv("KEYCLOAK_ADMIN_PASSWORD", "admin")
+ .withCopyFileToContainer(
+
org.testcontainers.utility.MountableFile.forClasspathResource("keycloak/realm-export.json"),
+ "/opt/keycloak/data/import/realm.json")
+ .withCommand("start-dev", "--import-realm")
+
.waitingFor(org.testcontainers.containers.wait.strategy.Wait.forHttp("/realms/testrealm/.well-known/openid-configuration").forStatusCode(200));
+
+ keycloakContainer.start();
+ assertTrue(keycloakContainer.isRunning());
+ } catch (Exception e) {
+ assumeNoException(e);
+ }
+ }
+
+ @AfterClass
+ public static void tearDownClass() {
+ if (keycloakContainer != null) {
+ keycloakContainer.stop();
+ }
+ }
+
+ @Test
+ public void testPac4jOidcLoginWithKeycloak() throws Exception {
+ LOGGER.info("Starting Pac4j OIDC login integration test with
Keycloak");
+
+ ServletContext context = setupKnoxEnvironment();
+ Pac4jFilters filters = setupPac4jFilters(context);
+ ClientCookieStore clientCookieStore = new ClientCookieStore();
+
+ LOGGER.info("Step 1: Making unauthenticated request to KnoxSSO");
+ MockHttpServletResponse redirectResponse =
triggerOidcRedirect(filters, clientCookieStore);
+ clientCookieStore.saveCookies(redirectResponse);
+
+ assertEquals("Should redirect to Keycloak", 302,
redirectResponse.getStatus());
+ String keycloakAuthUrl =
redirectResponse.getHeaders().get("Location").get(0);
+
+ verifyOidcRedirectUri(keycloakAuthUrl);
+ verifyKnoxSessionCookies(clientCookieStore);
+
+ LOGGER.info("Step 2: Authenticating with Keycloak directly via HTTP
client...");
+ KeycloakLoginResult loginResult =
authenticateWithKeycloak(keycloakAuthUrl, clientCookieStore);
+ clientCookieStore.saveCookies(loginResult.cookies);
+
+ assertNotNull("Redirect URL should not be null after successful
login", loginResult.redirectUrl);
+
+ LOGGER.info("Step 3: Processing OIDC callback from Keycloak");
+ MockHttpServletResponse callbackResponse =
processOidcCallback(filters, loginResult.redirectUrl, clientCookieStore);
+ clientCookieStore.saveCookies(callbackResponse);
+
+ assertEquals("Should redirect back to the original service URL", 302,
callbackResponse.getStatus());
+ String finalRedirectUrl =
callbackResponse.getHeaders().get("Location").get(0);
+ assertEquals(KNOXSSO_SERVICE_URL + "?" + ORIGINAL_URL + "=" +
HADOOP_SERVICE_URL, finalRedirectUrl);
+
+ LOGGER.info("Step 4: Executing identity filters with established Knox
session");
+ MockHttpServletResponse finalResponse =
executeKnoxPac4jFilters(filters, clientCookieStore);
+
+ assertEquals(0, finalResponse.getStatus()); // Dispatcher passes
through successfully
+ assertEquals("User identity should be resolved", USERNAME,
filters.adapter.getTestIdentifier());
+
+ LOGGER.info("Identity successfully resolved for user: {}",
filters.adapter.getTestIdentifier());
+ }
+
+ private ServletContext setupKnoxEnvironment() throws Exception {
+ final AliasService aliasService =
EasyMock.createNiceMock(AliasService.class);
+
EasyMock.expect(aliasService.getPasswordFromAliasForCluster(CLUSTER_NAME,
KnoxSessionStore.PAC4J_PASSWORD, true))
+ .andReturn(PAC4J_PASSWORD.toCharArray()).anyTimes();
+
EasyMock.expect(aliasService.getPasswordFromAliasForCluster(CLUSTER_NAME,
KnoxSessionStore.PAC4J_PASSWORD))
+ .andReturn(PAC4J_PASSWORD.toCharArray()).anyTimes();
+ EasyMock.replay(aliasService);
+
+ final DefaultCryptoService cryptoService = new DefaultCryptoService();
+ cryptoService.setAliasService(aliasService);
+
+ final GatewayServices services =
EasyMock.createNiceMock(GatewayServices.class);
+
EasyMock.expect(services.getService(ServiceType.CRYPTO_SERVICE)).andReturn(cryptoService);
+
EasyMock.expect(services.getService(ServiceType.ALIAS_SERVICE)).andReturn(aliasService);
+ EasyMock.replay(services);
+
+ final ServletContext context =
EasyMock.createNiceMock(ServletContext.class);
+ GatewayConfig gatewayConfig =
EasyMock.createNiceMock(GatewayConfig.class);
+
EasyMock.expect(context.getAttribute(GatewayConfig.GATEWAY_CONFIG_ATTRIBUTE)).andReturn(gatewayConfig);
+
EasyMock.expect(context.getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE)).andReturn(services);
+
EasyMock.expect(context.getAttribute(GatewayServices.GATEWAY_CLUSTER_ATTRIBUTE)).andReturn(CLUSTER_NAME);
+ EasyMock.replay(context);
+
+ return context;
+ }
+
+ private Pac4jFilters setupPac4jFilters(ServletContext context) throws
ServletException {
+ final FilterConfig config =
EasyMock.createNiceMock(FilterConfig.class);
+ EasyMock.expect(config.getServletContext()).andReturn(context);
+
EasyMock.expect(config.getInitParameter(Pac4jDispatcherFilter.PAC4J_CALLBACK_URL)).andReturn(PAC4J_CALLBACK_URL).anyTimes();
+
EasyMock.expect(config.getInitParameter(Pac4jIdentityAdapter.PAC4J_ID_ATTRIBUTE)).andReturn("customIdAttribute");
+
EasyMock.expect(config.getInitParameter("clientName")).andReturn("OidcClient").anyTimes();
+
EasyMock.expect(config.getInitParameter("oidc.id")).andReturn("test-client-id").anyTimes();
+
EasyMock.expect(config.getInitParameter("oidc.secret")).andReturn("test-client-secret").anyTimes();
+
+ String authServerUrl = String.format(Locale.ROOT, "http://%s:%d",
keycloakContainer.getHost(), keycloakContainer.getMappedPort(8080));
+ String discoveryUri = authServerUrl +
"/realms/testrealm/.well-known/openid-configuration";
+
EasyMock.expect(config.getInitParameter("oidc.discoveryUri")).andReturn(discoveryUri).anyTimes();
+
EasyMock.expect(config.getInitParameter("oidc.clientAuthenticationMethod")).andReturn("client_secret_basic").anyTimes();
+
EasyMock.expect(config.getInitParameter("oidc.preferredJwsAlgorithm")).andReturn("RS256").anyTimes();
+
EasyMock.expect(config.getInitParameter("oidc.scope")).andReturn("openid email
profile").anyTimes();
+
+ List<String> initParameterNames =
Arrays.asList(Pac4jDispatcherFilter.PAC4J_CALLBACK_URL,
+ "clientName", "oidc.id", "oidc.secret", "oidc.discoveryUri",
"oidc.clientAuthenticationMethod",
+ "oidc.preferredJwsAlgorithm", "oidc.scope");
+
EasyMock.expect(config.getInitParameterNames()).andReturn(Collections.enumeration(initParameterNames)).anyTimes();
+ EasyMock.replay(config);
+
+ final Pac4jDispatcherFilter dispatcher = new Pac4jDispatcherFilter();
+ dispatcher.init(config);
+
+ final Pac4jIdentityAdapter adapter = new Pac4jIdentityAdapter();
+ adapter.init(config);
+
+
Pac4jIdentityAdapter.setAuditor(EasyMock.createNiceMock(Auditor.class));
+ final AuditService auditService =
EasyMock.createNiceMock(AuditService.class);
+
EasyMock.expect(auditService.getContext()).andReturn(EasyMock.createNiceMock(AuditContext.class));
+ EasyMock.replay(auditService);
+ Pac4jIdentityAdapter.setAuditService(auditService);
+
+ return new Pac4jFilters(dispatcher, adapter);
+ }
+
+ private MockHttpServletResponse triggerOidcRedirect(Pac4jFilters filters,
ClientCookieStore clientCookieStore) throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ request.setAttribute(GatewayServices.GATEWAY_CLUSTER_ATTRIBUTE,
CLUSTER_NAME);
+ request.setRequestURL(KNOXSSO_SERVICE_URL + "?" + ORIGINAL_URL + "=" +
HADOOP_SERVICE_URL);
+ request.setCookies(clientCookieStore.getCookies().toArray(new
Cookie[0]));
+ request.setServerName(LOCALHOST);
+
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ FilterChain filterChain = EasyMock.createNiceMock(FilterChain.class);
+ filters.dispatcher.doFilter(request, response, filterChain);
+
+ return response;
+ }
+
+ private KeycloakLoginResult authenticateWithKeycloak(String authUrl,
ClientCookieStore clientCookieStore) throws Exception {
+ BasicCookieStore apacheCookieStore =
getBasicCookieStore(clientCookieStore);
+
+ String redirectUrlAfterLogin;
+ List<Cookie> setCookiesAfterLogin = new ArrayList<>();
+
+ try (CloseableHttpClient httpClient =
HttpClients.custom().setDefaultCookieStore(apacheCookieStore).build()) {
+ String loginPageHtml;
+ try (CloseableHttpResponse loginPageResponse =
httpClient.execute(new HttpGet(authUrl))) {
+ loginPageHtml =
EntityUtils.toString(loginPageResponse.getEntity());
+ }
+
+ Document doc = Jsoup.parse(loginPageHtml);
+ String postUrl = doc.select("#kc-form-login").attr("action");
+
+ HttpPost httpPost = new HttpPost(postUrl);
+ List<NameValuePair> params = new ArrayList<>();
+ params.add(new BasicNameValuePair("username", "user"));
+ params.add(new BasicNameValuePair("password", "user-password"));
+ params.add(new BasicNameValuePair("credentialId", ""));
+ httpPost.setEntity(new UrlEncodedFormEntity(params));
+
+ try (CloseableHttpResponse loginResponse =
httpClient.execute(httpPost)) {
+ int statusCode = loginResponse.getStatusLine().getStatusCode();
+ if (statusCode == 302) {
+ redirectUrlAfterLogin =
loginResponse.getFirstHeader("Location").getValue();
+ Header[] headers = loginResponse.getHeaders("Set-Cookie");
+ if (headers != null) {
+ for (Header header : headers) {
+
setCookiesAfterLogin.add(ClientCookieStore.parseCookieString(header.getValue()));
+ }
+ }
+ } else {
+ throw new RuntimeException("Keycloak login failed with
status: " + statusCode);
+ }
+ EntityUtils.consume(loginResponse.getEntity());
+ }
+ }
+
+ return new KeycloakLoginResult(redirectUrlAfterLogin,
setCookiesAfterLogin);
+ }
+
+ private static BasicCookieStore getBasicCookieStore(ClientCookieStore
clientCookieStore) {
+ BasicCookieStore basicCookieStore = new BasicCookieStore();
+
+ for (Cookie servletCookie : clientCookieStore.getCookies()) {
+ BasicClientCookie clientCookie = new
BasicClientCookie(servletCookie.getName(), servletCookie.getValue());
+ clientCookie.setDomain(servletCookie.getDomain() != null ?
servletCookie.getDomain() : LOCALHOST);
+ clientCookie.setPath(servletCookie.getPath() != null ?
servletCookie.getPath() : "/");
+ basicCookieStore.addCookie(clientCookie);
+ }
+ return basicCookieStore;
+ }
+
+ private MockHttpServletResponse processOidcCallback(Pac4jFilters filters,
String callbackUrl, ClientCookieStore clientCookieStore) throws Exception {
+ assertTrue("Expected Keycloak to redirect back to Knox, but got: " +
callbackUrl, callbackUrl.startsWith(KNOXSSO_SERVICE_URL));
+
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ request.setAttribute(GatewayServices.GATEWAY_CLUSTER_ATTRIBUTE,
CLUSTER_NAME);
+ request.setCookies(clientCookieStore.getCookies().toArray(new
Cookie[0]));
+ request.setRequestURL(callbackUrl);
+ request.setServerName(LOCALHOST);
+
+ String state = getQueryParameter(callbackUrl, "state");
+ String code = getQueryParameter(callbackUrl, "code");
+ String sessionState = getQueryParameter(callbackUrl, "session_state");
+
+ request.addParameter(Pac4jDispatcherFilter.PAC4J_CALLBACK_PARAMETER,
"true");
+ request.addParameter(Pac4jConstants.DEFAULT_CLIENT_NAME_PARAMETER,
CLIENT_CLASS);
+ request.addParameter("state", state);
+ request.addParameter("session_state", sessionState);
+ request.addParameter("code", code);
+
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ FilterChain filterChain = EasyMock.createNiceMock(FilterChain.class);
+ filters.dispatcher.doFilter(request, response, filterChain);
+
+ return response;
+ }
+
+ private MockHttpServletResponse executeKnoxPac4jFilters(Pac4jFilters
filters, ClientCookieStore clientCookieStore) throws Exception {
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ request.setAttribute(GatewayServices.GATEWAY_CLUSTER_ATTRIBUTE,
CLUSTER_NAME);
+ request.setCookies(clientCookieStore.getCookies().toArray(new
Cookie[0]));
+ request.setRequestURL(KNOXSSO_SERVICE_URL + "?" + ORIGINAL_URL + "=" +
HADOOP_SERVICE_URL);
+ request.setServerName(LOCALHOST);
+
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ FilterChain filterChain = EasyMock.createNiceMock(FilterChain.class);
+
+ filters.dispatcher.doFilter(request, response, filterChain);
+ filters.adapter.doFilter(request, response, filterChain);
+
+ return response;
+ }
+
+ private void verifyOidcRedirectUri(String locationHeader) throws Exception
{
+ String redirectUriParam = getQueryParameter(locationHeader,
"redirect_uri");
+
+ String redirectUri = URLDecoder.decode(redirectUriParam,
StandardCharsets.UTF_8.name());
+ String expectedRedirectUri = PAC4J_CALLBACK_URL + "?"
+ + Pac4jDispatcherFilter.PAC4J_CALLBACK_PARAMETER + "=true&"
+ + Pac4jConstants.DEFAULT_CLIENT_NAME_PARAMETER + "=" + CLIENT_CLASS;
+ assertEquals(expectedRedirectUri, redirectUri);
+ }
+
+ private void verifyKnoxSessionCookies(ClientCookieStore clientCookieStore)
{
+ boolean hasRequestedUrlCookie = clientCookieStore.getCookies().stream()
+ .anyMatch(c ->
c.getName().equals(KnoxSessionStore.PAC4J_SESSION_PREFIX +
Pac4jConstants.REQUESTED_URL));
+ assertTrue("Must contain PAC4J requested URL cookie",
hasRequestedUrlCookie);
+
+ long csrfTokenCount = clientCookieStore.getCookies().stream()
+ .filter(c -> c.getName().equals(KnoxSessionStore.PAC4J_SESSION_PREFIX
+ Pac4jConstants.CSRF_TOKEN)).count();
+ assertEquals("Must contain exactly one PAC4J CSRF token cookie", 1,
csrfTokenCount);
+ }
+
+ private String getQueryParameter(String url, String paramName) throws
Exception {
+ List<NameValuePair> params = URLEncodedUtils.parse(new URI(url),
StandardCharsets.UTF_8);
+ return params.stream()
+ .filter(p -> paramName.equals(p.getName()))
+ .findFirst()
+ .map(NameValuePair::getValue)
+ .orElse(null);
+ }
+
+ private static class Pac4jFilters {
+ final Pac4jDispatcherFilter dispatcher;
+ final Pac4jIdentityAdapter adapter;
+
+ Pac4jFilters(Pac4jDispatcherFilter dispatcher, Pac4jIdentityAdapter
adapter) {
+ this.dispatcher = dispatcher;
+ this.adapter = adapter;
+ }
+ }
+
+ private static class KeycloakLoginResult {
+ final String redirectUrl;
+ final List<Cookie> cookies;
+
+ KeycloakLoginResult(String redirectUrl, List<Cookie> cookies) {
+ this.redirectUrl = redirectUrl;
+ this.cookies = cookies;
+ }
+ }
+
+ /**
+ * Stores and updates cookies across requests to maintain state.
+ */
+ private static class ClientCookieStore {
+ private final Map<String, Cookie> cookies = new HashMap<>();
+
+ public List<Cookie> getCookies() {
+ return new ArrayList<>(cookies.values());
+ }
+
+ public void saveCookies(MockHttpServletResponse response) {
+ if (response.getCookies() != null) {
+ for (Cookie c : response.getCookies()) {
+ cookies.put(c.getName(), c);
+ }
+ }
+ List<String> headers = response.getHeaders().get("Set-Cookie");
+ if (headers != null) {
+ for (String header : headers) {
+ Cookie c = parseCookieString(header);
+ cookies.put(c.getName(), c);
+ }
+ }
+ }
+
+ public void saveCookies(List<Cookie> newCookies) {
+ if (newCookies != null) {
+ for (Cookie c : newCookies) {
+ cookies.put(c.getName(), c);
+ }
+ }
+ }
+
+ public static Cookie parseCookieString(String setCookieHeader) {
+ String[] cookieParts = setCookieHeader.split(";");
+ String[] nameValuePairs = cookieParts[0].trim().split("=", 2);
+ return new Cookie(nameValuePairs[0].trim(),
nameValuePairs[1].trim());
+ }
+ }
+
+}
diff --git
a/gateway-provider-security-pac4j/src/test/resources/keycloak/realm-export.json
b/gateway-provider-security-pac4j/src/test/resources/keycloak/realm-export.json
new file mode 100644
index 000000000..f4ad7cc76
--- /dev/null
+++
b/gateway-provider-security-pac4j/src/test/resources/keycloak/realm-export.json
@@ -0,0 +1,189 @@
+{
+ "realm": "testrealm",
+ "enabled": true,
+ "roles": {
+ "realm": [
+ { "name": "admin", "description": "Administrative privileges" },
+ { "name": "user", "description": "Standard user privileges" }
+ ]
+ },
+ "groups": [
+ {
+ "name": "admin",
+ "path": "/admin",
+ "realmRoles": ["admin"]
+ },
+ {
+ "name": "users",
+ "path": "/users",
+ "realmRoles": ["user"]
+ }
+ ],
+ "clientScopes": [
+ {
+ "name": "email",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "true"
+ },
+ "protocolMappers": [
+ {
+ "name": "email",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "config": {
+ "user.attribute": "email",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "email",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "email verified",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "config": {
+ "user.attribute": "emailVerified",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "email_verified",
+ "jsonType.label": "boolean"
+ }
+ }
+ ]
+ },
+ {
+ "name": "profile",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "true"
+ },
+ "protocolMappers": [
+ {
+ "name": "full name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-full-name-mapper",
+ "config": {
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "true"
+ }
+ },
+ {
+ "name": "given name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "config": {
+ "user.attribute": "firstName",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "given_name",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "family name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "config": {
+ "user.attribute": "lastName",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "family_name",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "username",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "config": {
+ "user.attribute": "username",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "preferred_username",
+ "jsonType.label": "String"
+ }
+ }
+ ]
+ },
+ {
+ "name": "custom-identity",
+ "protocol": "openid-connect",
+ "protocolMappers": [
+ {
+ "name": "map-custom-id",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "config": {
+ "user.attribute": "customIdAttribute",
+ "claim.name": "customIdAttribute",
+ "jsonType.label": "String",
+ "id.token.claim": "true",
+ "access.token.claim": "true"
+ }
+ },
+ {
+ "name": "group-mapper",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-group-membership-mapper",
+ "config": {
+ "full.path": "false",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "groups"
+ }
+ }
+ ]
+ }
+ ],
+ "defaultDefaultClientScopes": [
+ "role_list",
+ "profile",
+ "email",
+ "custom-identity"
+ ],
+ "clients": [
+ {
+ "clientId": "test-client-id",
+ "enabled": true,
+ "protocol": "openid-connect",
+ "publicClient": false,
+ "secret": "test-client-secret",
+ "redirectUris": ["https://localhost:8443/gateway/*"],
+ "webOrigins": ["https://localhost:8443/gateway/*"],
+ "standardFlowEnabled": true,
+ "directAccessGrantsEnabled": true,
+ "defaultClientScopes": [
+ "email",
+ "profile",
+ "custom-identity"
+ ]
+ }
+ ],
+ "users": [
+ {
+ "username": "admin",
+ "email": "[email protected]",
+ "firstName": "Joe",
+ "lastName": "Admin",
+ "enabled": true,
+ "credentials": [{ "type": "password", "value": "admin-password",
"temporary": false }],
+ "attributes": { "customIdAttribute": ["JoeAdmin"] },
+ "groups": ["admin"]
+ },
+ {
+ "username": "user",
+ "email": "[email protected]",
+ "firstName": "Jane",
+ "lastName": "User",
+ "enabled": true,
+ "credentials": [{ "type": "password", "value": "user-password",
"temporary": false }],
+ "attributes": { "customIdAttribute": ["JaneUser"] },
+ "groups": ["users"]
+ }
+ ]
+}
diff --git a/gateway-provider-security-pac4j/src/test/resources/log4j2-test.xml
b/gateway-provider-security-pac4j/src/test/resources/log4j2-test.xml
new file mode 100644
index 000000000..b91f3c0df
--- /dev/null
+++ b/gateway-provider-security-pac4j/src/test/resources/log4j2-test.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ 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.
+-->
+<Configuration>
+ <Appenders>
+ <Console name="stdout" target="SYSTEM_OUT">
+ <PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L -
%m%n" />
+ </Console>
+ </Appenders>
+ <Loggers>
+ <Root level="info">
+ <AppenderRef ref="stdout"/>
+ </Root>
+ </Loggers>
+</Configuration>
diff --git a/pom.xml b/pom.xml
index 8fed262a9..1ba43d158 100644
--- a/pom.xml
+++ b/pom.xml
@@ -243,6 +243,7 @@
<joda-time.version>2.10.8</joda-time.version>
<json-path.version>2.9.0</json-path.version>
<json-smart.version>2.5.2</json-smart.version>
+ <jsoup.version>1.22.2</jsoup.version>
<junit.version>4.13.2</junit.version>
<kotlin-stdlib.version>1.9.10</kotlin-stdlib.version>
<lang-tag.version>1.5</lang-tag.version>
@@ -265,6 +266,7 @@
<postgresql.version>42.4.4</postgresql.version>
<mysql.version>8.0.28</mysql.version>
<mariadb.connector.version>3.3.0</mariadb.connector.version>
+ <oauth2-oidc-sdk.version>9.13</oauth2-oidc-sdk.version>
<okio.version>3.6.0</okio.version>
<protobuf.version>3.25.5</protobuf.version>
<powermock.version>2.0.9</powermock.version>
@@ -287,7 +289,7 @@
<swagger-annotations.version>1.6.2</swagger-annotations.version>
<swagger-maven-plugin.version>3.1.7</swagger-maven-plugin.version>
<taglibs-standard.version>1.2.5</taglibs-standard.version>
- <testcontainers.version>1.15.1</testcontainers.version>
+ <testcontainers.version>1.16.3</testcontainers.version>
<txw2.version>2.4.0-b180830.0438</txw2.version>
<velocity.version>2.4.1</velocity.version>
<woodstox-core.version>6.4.0</woodstox-core.version>
@@ -1430,6 +1432,11 @@
<artifactId>lang-tag</artifactId>
<version>${lang-tag.version}</version>
</dependency>
+ <dependency>
+ <groupId>com.nimbusds</groupId>
+ <artifactId>oauth2-oidc-sdk</artifactId>
+ <version>${oauth2-oidc-sdk.version}</version>
+ </dependency>
<dependency>
<groupId>net.minidev</groupId>
@@ -2452,6 +2459,12 @@
<groupId>org.pac4j</groupId>
<artifactId>pac4j-oidc</artifactId>
<version>${pac4j.version}</version>
+ <exclusions>
+ <exclusion>
+ <groupId>com.nimbusds</groupId>
+ <artifactId>oauth2-oidc-sdk</artifactId>
+ </exclusion>
+ </exclusions>
</dependency>
<dependency>
<groupId>org.pac4j</groupId>
@@ -2688,6 +2701,12 @@
<version>${hamcrest.version}</version>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.jsoup</groupId>
+ <artifactId>jsoup</artifactId>
+ <version>${jsoup.version}</version>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>uk.co.datumedge</groupId>
<artifactId>hamcrest-json</artifactId>