Create clustering test cases for OIDC flows (#40623)

Closes #39965

Signed-off-by: Ryan Emerson <remerson@redhat.com>
Signed-off-by: Michal Hajas <mhajas@redhat.com>
Co-authored-by: Michal Hajas <mhajas@redhat.com>
This commit is contained in:
Ryan Emerson 2025-06-25 14:06:10 +01:00 committed by GitHub
parent 4f05b62e99
commit 9543008899
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 204 additions and 62 deletions

View File

@ -18,5 +18,9 @@
<artifactId>keycloak-test-framework-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-http-proxy</artifactId>
</dependency>
</dependencies>
</project>

View File

@ -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<Integer, KeycloakUrls> urls = new HashMap<>();
private final HashMap<Integer, Origin> 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<ProxyResponse> 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) {
}
}

View File

@ -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<LoadBalancer, InjectLoadBalancer> {
public class LoadBalancerSupplier implements Supplier<LoadBalancer, InjectLoadBalancer>, KeycloakServerConfigInterceptor<LoadBalancer, InjectLoadBalancer> {
@Override
public LoadBalancer getValue(InstanceContext<LoadBalancer, InjectLoadBalancer> instanceContext) {
@ -20,8 +23,23 @@ public class LoadBalancerSupplier implements Supplier<LoadBalancer, InjectLoadBa
throw new IllegalStateException("Load balancer can only be used with ClusteredKeycloakServer");
}
@Override
public void close(InstanceContext<LoadBalancer, InjectLoadBalancer> instanceContext) {
instanceContext.getValue().close();
}
@Override
public boolean compatible(InstanceContext<LoadBalancer, InjectLoadBalancer> a, RequestedInstance<LoadBalancer, InjectLoadBalancer> b) {
return true;
}
@Override
public int order() {
return SupplierOrder.BEFORE_REALM;
}
@Override
public KeycloakServerConfigBuilder intercept(KeycloakServerConfigBuilder serverConfig, InstanceContext<LoadBalancer, InjectLoadBalancer> instanceContext) {
return serverConfig.option("hostname", LoadBalancer.HOSTNAME);
}
}

View File

@ -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) {

View File

@ -37,6 +37,4 @@ public class ClusteredKeycloakServerSupplier extends AbstractKeycloakServerSuppl
protected String cache() {
return "ispn";
}
}

View File

@ -41,5 +41,27 @@
<groupId>org.keycloak.testframework</groupId>
<artifactId>keycloak-test-framework-db-postgres</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak.testframework</groupId>
<artifactId>keycloak-test-framework-oauth</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak.testframework</groupId>
<artifactId>keycloak-test-framework-ui</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<systemPropertyVariables>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
<java.util.concurrent.ForkJoinPool.common.threadFactory>io.quarkus.bootstrap.forkjoin.QuarkusForkJoinWorkerThreadFactory</java.util.concurrent.ForkJoinPool.common.threadFactory>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -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");
}
}
}

View File

@ -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));
}
}

View File

@ -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
kc.test.log.category."managed.db".level=WARN
kc.test.log.category."org.keycloak.testframework.clustering".level=WARN