diff --git a/test-framework/clustering/pom.xml b/test-framework/clustering/pom.xml
index 5b1791d6fd4..c6e9088957f 100644
--- a/test-framework/clustering/pom.xml
+++ b/test-framework/clustering/pom.xml
@@ -18,5 +18,9 @@
keycloak-test-framework-core
${project.version}
+
+ io.vertx
+ vertx-http-proxy
+
\ No newline at end of file
diff --git a/test-framework/clustering/src/main/java/org/keycloak/testframework/clustering/LoadBalancer.java b/test-framework/clustering/src/main/java/org/keycloak/testframework/clustering/LoadBalancer.java
index 1b56bcdec13..232e6845a48 100644
--- a/test-framework/clustering/src/main/java/org/keycloak/testframework/clustering/LoadBalancer.java
+++ b/test-framework/clustering/src/main/java/org/keycloak/testframework/clustering/LoadBalancer.java
@@ -1,26 +1,72 @@
package org.keycloak.testframework.clustering;
-import org.keycloak.testframework.server.ClusteredKeycloakServer;
-import org.keycloak.testframework.server.KeycloakUrls;
-
import java.util.HashMap;
+import io.vertx.httpproxy.ProxyContext;
+import io.vertx.httpproxy.ProxyInterceptor;
+import io.vertx.httpproxy.ProxyResponse;
+import org.jboss.logging.Logger;
+import org.keycloak.testframework.server.ClusteredKeycloakServer;
+import org.keycloak.testframework.server.KeycloakUrls;
+
+import io.vertx.core.Future;
+import io.vertx.core.Vertx;
+import io.vertx.core.http.HttpClient;
+import io.vertx.core.http.HttpServer;
+import io.vertx.httpproxy.HttpProxy;
+
public class LoadBalancer {
+
+ private static final Logger LOGGER = Logger.getLogger(LoadBalancer.class);
+ public static final String HOSTNAME = "http://localhost:9999";
+
private final ClusteredKeycloakServer server;
- private final HashMap urls = new HashMap<>();
+ private final HashMap urls = new HashMap<>();
+ private final Vertx vertx;
+ private final HttpProxy proxy;
public LoadBalancer(ClusteredKeycloakServer server) {
this.server = server;
+
+ this.vertx = Vertx.vertx();
+ HttpClient proxyClient = vertx.createHttpClient();
+ proxy = HttpProxy.reverseProxy(proxyClient);
+ proxy.addInterceptor(new ProxyInterceptor() {
+ @Override
+ public Future handleProxyRequest(ProxyContext context) {
+ LOGGER.debugf("Proxy request intercepted: %s", context.request().getURI());
+ return ProxyInterceptor.super.handleProxyRequest(context);
+ }
+ });
+ node(0);
+
+ HttpServer proxyServer = vertx.createHttpServer();
+ proxyServer.requestHandler(proxy).listen(9999, "localhost");
}
- public KeycloakUrls node(int nodeIndex) {
- if (nodeIndex >= server.clusterSize()) {
- throw new IllegalArgumentException("Node index out of bounds. Requested nodeIndex: %d, cluster size: %d".formatted(server.clusterSize(), nodeIndex));
+ public void node(int index) {
+ Origin origin = origin(index);
+ LOGGER.debugf("Setting proxy origin to: %s:%d", origin.host, origin.port);
+ proxy.origin(origin.port, origin.host);
+ }
+
+ public KeycloakUrls nodeUrls(int index) {
+ return origin(index).urls;
+ }
+
+ private Origin origin(int index) {
+ if (index >= server.clusterSize()) {
+ throw new IllegalArgumentException("Node index out of bounds. Requested nodeIndex: %d, cluster size: %d".formatted(server.clusterSize(), index));
}
- return urls.computeIfAbsent(nodeIndex, i -> new KeycloakUrls(server.getBaseUrl(i), server.getManagementBaseUrl(i)));
+ return urls.computeIfAbsent(index, i ->
+ new Origin("localhost", server.getBasePort(i), new KeycloakUrls(server.getBaseUrl(i), server.getManagementBaseUrl(i)))
+ );
}
- public int clusterSize() {
- return server.clusterSize();
+ public void close() {
+ Future.await(vertx.close());
+ }
+
+ record Origin(String host, int port, KeycloakUrls urls) {
}
}
diff --git a/test-framework/clustering/src/main/java/org/keycloak/testframework/clustering/LoadBalancerSupplier.java b/test-framework/clustering/src/main/java/org/keycloak/testframework/clustering/LoadBalancerSupplier.java
index de6344c4b0d..2010403be91 100644
--- a/test-framework/clustering/src/main/java/org/keycloak/testframework/clustering/LoadBalancerSupplier.java
+++ b/test-framework/clustering/src/main/java/org/keycloak/testframework/clustering/LoadBalancerSupplier.java
@@ -4,10 +4,13 @@ import org.keycloak.testframework.annotations.InjectLoadBalancer;
import org.keycloak.testframework.injection.InstanceContext;
import org.keycloak.testframework.injection.RequestedInstance;
import org.keycloak.testframework.injection.Supplier;
+import org.keycloak.testframework.injection.SupplierOrder;
import org.keycloak.testframework.server.ClusteredKeycloakServer;
import org.keycloak.testframework.server.KeycloakServer;
+import org.keycloak.testframework.server.KeycloakServerConfigBuilder;
+import org.keycloak.testframework.server.KeycloakServerConfigInterceptor;
-public class LoadBalancerSupplier implements Supplier {
+public class LoadBalancerSupplier implements Supplier, KeycloakServerConfigInterceptor {
@Override
public LoadBalancer getValue(InstanceContext instanceContext) {
@@ -20,8 +23,23 @@ public class LoadBalancerSupplier implements Supplier instanceContext) {
+ instanceContext.getValue().close();
+ }
+
@Override
public boolean compatible(InstanceContext a, RequestedInstance b) {
return true;
}
+
+ @Override
+ public int order() {
+ return SupplierOrder.BEFORE_REALM;
+ }
+
+ @Override
+ public KeycloakServerConfigBuilder intercept(KeycloakServerConfigBuilder serverConfig, InstanceContext instanceContext) {
+ return serverConfig.option("hostname", LoadBalancer.HOSTNAME);
+ }
}
diff --git a/test-framework/clustering/src/main/java/org/keycloak/testframework/server/ClusteredKeycloakServer.java b/test-framework/clustering/src/main/java/org/keycloak/testframework/server/ClusteredKeycloakServer.java
index c3a7f136cc6..47c8cffc960 100644
--- a/test-framework/clustering/src/main/java/org/keycloak/testframework/server/ClusteredKeycloakServer.java
+++ b/test-framework/clustering/src/main/java/org/keycloak/testframework/server/ClusteredKeycloakServer.java
@@ -17,15 +17,12 @@
package org.keycloak.testframework.server;
-import java.net.URISyntaxException;
-import java.nio.file.Path;
-import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Objects;
-import io.quarkus.bootstrap.utils.BuildToolHelper;
import org.jboss.logging.Logger;
import org.keycloak.it.utils.DockerKeycloakDistribution;
+import org.keycloak.testframework.clustering.LoadBalancer;
import org.keycloak.testframework.database.JBossLogConsumer;
import org.testcontainers.images.RemoteDockerImage;
import org.testcontainers.utility.DockerImageName;
@@ -122,7 +119,7 @@ public class ClusteredKeycloakServer implements KeycloakServer {
@Override
public String getBaseUrl() {
- return getBaseUrl(0);
+ return LoadBalancer.HOSTNAME;
}
@Override
@@ -130,8 +127,12 @@ public class ClusteredKeycloakServer implements KeycloakServer {
return getManagementBaseUrl(0);
}
+ public int getBasePort(int index) {
+ return containers[index].getMappedPort(REQUEST_PORT);
+ }
+
public String getBaseUrl(int index) {
- return "http://localhost:%d".formatted(containers[index].getMappedPort(REQUEST_PORT));
+ return "http://localhost:%d".formatted(getBasePort(index));
}
public String getManagementBaseUrl(int index) {
diff --git a/test-framework/clustering/src/main/java/org/keycloak/testframework/server/ClusteredKeycloakServerSupplier.java b/test-framework/clustering/src/main/java/org/keycloak/testframework/server/ClusteredKeycloakServerSupplier.java
index b7fbc148237..34f558ef7ee 100644
--- a/test-framework/clustering/src/main/java/org/keycloak/testframework/server/ClusteredKeycloakServerSupplier.java
+++ b/test-framework/clustering/src/main/java/org/keycloak/testframework/server/ClusteredKeycloakServerSupplier.java
@@ -37,6 +37,4 @@ public class ClusteredKeycloakServerSupplier extends AbstractKeycloakServerSuppl
protected String cache() {
return "ispn";
}
-
-
}
diff --git a/tests/clustering/pom.xml b/tests/clustering/pom.xml
index 70e97805ee2..1abcc44a49c 100644
--- a/tests/clustering/pom.xml
+++ b/tests/clustering/pom.xml
@@ -41,5 +41,27 @@
org.keycloak.testframework
keycloak-test-framework-db-postgres
+
+ org.keycloak.testframework
+ keycloak-test-framework-oauth
+
+
+ org.keycloak.testframework
+ keycloak-test-framework-ui
+
+
+
+
+
+ maven-surefire-plugin
+
+
+ org.jboss.logmanager.LogManager
+ io.quarkus.bootstrap.forkjoin.QuarkusForkJoinWorkerThreadFactory
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/clustering/src/test/java/org/keycloak/tests/compatibility/ClusteredOAuthClientTest.java b/tests/clustering/src/test/java/org/keycloak/tests/compatibility/ClusteredOAuthClientTest.java
new file mode 100644
index 00000000000..b2515ea2b6f
--- /dev/null
+++ b/tests/clustering/src/test/java/org/keycloak/tests/compatibility/ClusteredOAuthClientTest.java
@@ -0,0 +1,92 @@
+package org.keycloak.tests.compatibility;
+
+import org.htmlunit.WebClient;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.keycloak.testframework.annotations.InjectLoadBalancer;
+import org.keycloak.testframework.annotations.InjectUser;
+import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
+import org.keycloak.testframework.clustering.LoadBalancer;
+import org.keycloak.testframework.oauth.OAuthClient;
+import org.keycloak.testframework.oauth.annotations.InjectOAuthClient;
+import org.keycloak.testframework.realm.ManagedUser;
+import org.keycloak.testframework.realm.UserConfig;
+import org.keycloak.testframework.realm.UserConfigBuilder;
+import org.keycloak.testframework.ui.annotations.InjectWebDriver;
+import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
+import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse;
+import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.htmlunit.HtmlUnitDriver;
+
+@KeycloakIntegrationTest
+public class ClusteredOAuthClientTest {
+
+ @InjectUser(config = OAuthUserConfig.class)
+ ManagedUser user;
+
+ @InjectLoadBalancer
+ LoadBalancer loadBalancer;
+
+ @InjectOAuthClient
+ OAuthClient oauth;
+
+ @InjectWebDriver
+ WebDriver driver;
+
+ @AfterEach
+ public void cleanup() {
+ loadBalancer.node(0);
+ driver.navigate().to("about:blank");
+ if (driver instanceof HtmlUnitDriver htmlUnitDriver) {
+ WebClient webClient = htmlUnitDriver.getWebClient();
+ webClient.getCache().clear();
+ webClient.getCookieManager().clearCookies();
+ webClient.reset();
+ }
+ }
+
+ @ParameterizedTest
+ @CsvSource({"0, 1", "1, 0"})
+ public void testAccessTokenRefresh(int grantNode, int refreshNode) {
+ loadBalancer.node(grantNode);
+ AccessTokenResponse accessTokenResponse = oauth.doPasswordGrantRequest(user.getUsername(), user.getPassword());
+
+ loadBalancer.node(refreshNode);
+ AccessTokenResponse refreshResponse = oauth.doRefreshTokenRequest(accessTokenResponse.getRefreshToken());
+ Assertions.assertTrue(refreshResponse.isSuccess());
+ Assertions.assertNotEquals(accessTokenResponse.getAccessToken(), refreshResponse.getAccessToken());
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "0, 0, 1",
+ "0, 1, 0",
+ "0, 1, 1",
+ "1, 0, 0",
+ "1, 1, 0",
+ "1, 0, 1",
+ })
+ public void testLoginLogout(int loginNode, int tokenNode, int logoutNode) {
+ loadBalancer.node(loginNode);
+ AuthorizationEndpointResponse authResponse = oauth.doLogin(user.getUsername(), user.getPassword());
+ Assertions.assertTrue(authResponse.isRedirected());
+
+ loadBalancer.node(tokenNode);
+ AccessTokenResponse accessTokenResponse = oauth.doPasswordGrantRequest(user.getUsername(), user.getPassword());
+ Assertions.assertTrue(accessTokenResponse.isSuccess());
+
+ loadBalancer.node(logoutNode);
+ oauth.logoutForm().idTokenHint(accessTokenResponse.getIdToken()).open();
+ }
+
+ public static class OAuthUserConfig implements UserConfig {
+ @Override
+ public UserConfigBuilder configure(UserConfigBuilder user) {
+ return user.username("myuser").name("First", "Last")
+ .email("test@local")
+ .password("password");
+ }
+ }
+}
diff --git a/tests/clustering/src/test/java/org/keycloak/tests/compatibility/MixedVersionClusterTest.java b/tests/clustering/src/test/java/org/keycloak/tests/compatibility/MixedVersionClusterTest.java
deleted file mode 100644
index 1b18cbd8357..00000000000
--- a/tests/clustering/src/test/java/org/keycloak/tests/compatibility/MixedVersionClusterTest.java
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * Copyright 2025 Red Hat, Inc. and/or its affiliates
- * and other contributors as indicated by the @author tags.
- *
- * Licensed 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.keycloak.tests.compatibility;
-
-import java.util.concurrent.TimeUnit;
-
-import org.junit.jupiter.api.Assumptions;
-import org.junit.jupiter.api.Test;
-import org.keycloak.testframework.annotations.InjectLoadBalancer;
-import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
-import org.keycloak.testframework.clustering.LoadBalancer;
-
-@KeycloakIntegrationTest
-public class MixedVersionClusterTest {
-
- @InjectLoadBalancer
- LoadBalancer loadBalancer;
-
- @Test
- public void testUrls() throws InterruptedException {
- // TODO annotation based to skip if running in non-clustered mode.
- Assumptions.assumeTrue(loadBalancer.clusterSize() == 2);
- System.out.println("url0->" + loadBalancer.node(0).getBaseUrl());
- System.out.println("url1->" + loadBalancer.node(1).getBaseUrl());
- Thread.sleep(TimeUnit.MINUTES.toMillis(1));
- }
-}
diff --git a/tests/clustering/src/test/resources/keycloak-test.properties b/tests/clustering/src/test/resources/keycloak-test.properties
index f7b5160aaf8..0b26865a61e 100644
--- a/tests/clustering/src/test/resources/keycloak-test.properties
+++ b/tests/clustering/src/test/resources/keycloak-test.properties
@@ -1,4 +1,6 @@
kc.test.server=cluster
+# As clustering is dependent on JDBC_PING, we must use a real database for cluster tests
+kc.test.database=postgres
kc.test.log.level=WARN
@@ -9,4 +11,5 @@ kc.test.log.category."org.keycloak.tests".level=INFO
kc.test.log.category."testinfo".level=INFO
kc.test.log.category."org.keycloak".level=WARN
kc.test.log.category."managed.keycloak".level=WARN
-kc.test.log.category."managed.db".level=WARN
\ No newline at end of file
+kc.test.log.category."managed.db".level=WARN
+kc.test.log.category."org.keycloak.testframework.clustering".level=WARN
\ No newline at end of file