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>


Reply via email to