Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
timeout-minutes: 45
strategy:
matrix:
suite: [Oid4VpTestSuite, Oid4VciHaipTestSuite, Oid4VciNonHaipTestSuite]
suite: [Oid4VpTestSuite, Oid4VciHaipTestSuite, Oid4VciNonHaipTestSuite, OidcTestSuite, OidcFormPostTestSuite, OidcDynamicTestSuite]
fail-fast: false
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright 2026 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.testframework.conformance;

/**
* TLS settings shared by the conformance test servers.
*
* FAPI2 requires the TLS layer to only offer BCP195 (RFC 9325) recommended cipher suites for TLS 1.2. The default JVM
* cipher list includes non-recommended suites, which the conformance suite TLS checks reject, so the test servers are
* restricted to the recommended AEAD suites. The TLS 1.3 suites must be listed too, as restricting the cipher list
* would otherwise disable TLS 1.3 entirely and the suite checks that TLS 1.3 is negotiable where offered.
*/
public final class ConformanceTls {

public static final String PROTOCOLS = "TLSv1.3,TLSv1.2";
public static final String BCP195_CIPHERS = String.join(",",
"TLS_AES_128_GCM_SHA256",
"TLS_AES_256_GCM_SHA384",
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384");

private ConformanceTls() {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@
package org.keycloak.testframework.conformance;

import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
import java.security.cert.CertificateEncodingException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.util.Base64;
import java.util.List;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
Expand Down Expand Up @@ -62,6 +65,8 @@ public final class OpenIdConformanceServer implements AutoCloseable {
private final ConformanceApiClient client;
private final URI baseUri;
private final X509Certificate nginxCertificate;
// started on first use, as only some tests make Keycloak call the suite
private SuiteTunnel tunnel;

private OpenIdConformanceServer(Network network, GenericContainer<?> mongo, GenericContainer<?> server,
GenericContainer<?> nginx, URI baseUri, SSLContext sslContext, X509Certificate nginxCertificate) {
Expand Down Expand Up @@ -151,7 +156,34 @@ public X509Certificate nginxCertificate() {
return nginxCertificate;
}

// Rewrites a suite internal URI to one reachable from the test JVM.
// The suite's nginx TLS certificate PEM encoded, e.g. for a truststore of Keycloak, as Keycloak calls the suite
// itself for some flows, e.g. back-channel logout.
public String certificatePem() {
try {
return "-----BEGIN CERTIFICATE-----\n"
+ Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.US_ASCII)).encodeToString(nginxCertificate.getEncoded())
+ "\n-----END CERTIFICATE-----";
} catch (CertificateEncodingException e) {
throw new RuntimeException("Failed to encode the conformance suite TLS certificate", e);
}
}

/**
* The value of the {@code proxy-mappings} option of Keycloak's outgoing HTTP client
* ({@code spi-connections-http-client--default--proxy-mappings}) under which Keycloak reaches the URLs the suite
* publishes on its container internal host, e.g. the jwks_uri, request_uri or sector_identifier_uri of a
* dynamically registered client. The requests go through a tunnel in the test JVM to the suite's port on the Docker
* host.
*/
public synchronized String keycloakProxyMapping() {
if (tunnel == null) {
tunnel = new SuiteTunnel(INTERNAL_BASE_URI, baseUri.getHost(), baseUri.getPort());
}
return tunnel.keycloakProxyMapping();
}

// Rewrites a suite internal URI to one reachable from the test JVM, and from Keycloak, which runs on the Docker host
// as well, e.g. for the endpoints Keycloak calls itself such as the back-channel logout endpoint.
public URI externalUri(URI internalUri) {
if (!INTERNAL_BASE_URI.getHost().equals(internalUri.getHost())) {
return internalUri;
Expand All @@ -162,6 +194,12 @@ public URI externalUri(URI internalUri) {

@Override
public void close() {
synchronized (this) {
if (tunnel != null) {
tunnel.close();
tunnel = null;
}
}
List.of(nginx, server, mongo).forEach(GenericContainer::stop);
network.close();
synchronized (OpenIdConformanceServer.class) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
/*
* Copyright 2026 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.testframework.conformance;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

import org.jboss.logging.Logger;

/**
* An HTTP proxy in the test JVM through which Keycloak reaches the suite's container internal host, e.g. to fetch the
* jwks_uri, request_uri or sector_identifier_uri a dynamically registered client points at. The suite publishes these
* URLs on {@link OpenIdConformanceServer#INTERNAL_BASE_URI}, which only resolves on the containers' network, while
* Keycloak runs on the Docker host. Keycloak's outgoing HTTP client sends the requests for that host through this proxy
* ({@link #keycloakProxyMapping}), which tunnels them (HTTP CONNECT) to the suite's port published on the Docker host.
* TLS stays end to end between Keycloak and the suite.
*/
final class SuiteTunnel implements AutoCloseable {

private static final Logger LOGGER = Logger.getLogger(SuiteTunnel.class);

private static final int MAX_REQUEST_HEADER_BYTES = 16 * 1024;

private final String internalAuthority;
private final String targetHost;
private final int targetPort;
private final ServerSocket serverSocket;
private final Set<Socket> sockets = ConcurrentHashMap.newKeySet();
private volatile boolean closed;
private final ExecutorService executor = Executors.newCachedThreadPool(runnable -> {
Thread thread = new Thread(runnable, "conformance-suite-tunnel");
thread.setDaemon(true);
return thread;
});

/**
* Tunnels CONNECT requests for the authority of {@code internalUri} to {@code targetHost}:{@code targetPort}.
*/
SuiteTunnel(URI internalUri, String targetHost, int targetPort) {
this.internalAuthority = internalUri.getHost() + ":" + internalUri.getPort();
this.targetHost = targetHost;
this.targetPort = targetPort;
try {
serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0));
} catch (IOException e) {
throw new RuntimeException("Failed to start the conformance suite tunnel", e);
}
executor.execute(this::accept);
}

/**
* The value of Keycloak's {@code proxy-mappings} option of its outgoing HTTP client sending the requests for the
* suite's internal host through this proxy.
*/
String keycloakProxyMapping() {
return proxyMapping(internalAuthority.substring(0, internalAuthority.indexOf(':')), serverSocket.getInetAddress(),
serverSocket.getLocalPort());
}

/**
* A {@code proxy-mappings} entry sending the requests for {@code host} through the proxy at {@code address} and
* {@code port}.
*/
static String proxyMapping(String host, InetAddress address, int port) {
// an IPv6 address, e.g. the loopback address with java.net.preferIPv6Addresses, needs brackets in a URI
String proxyHost = address instanceof Inet6Address ? "[" + address.getHostAddress() + "]" : address.getHostAddress();
return host.replace(".", "\\.") + ";http://" + proxyHost + ":" + port;
}

private void accept() {
while (!serverSocket.isClosed()) {
try {
Socket client = track(serverSocket.accept());
executor.execute(() -> tunnel(client));
} catch (IOException e) {
if (!serverSocket.isClosed()) {
LOGGER.warn("Conformance suite tunnel failed to accept a connection: " + e.getMessage());
}
}
}
}

private void tunnel(Socket client) {
try (client) {
String requestLine = readRequestHeader(client.getInputStream());
String[] parts = requestLine.split(" ");
if (parts.length < 2 || !"CONNECT".equals(parts[0]) || !internalAuthority.equalsIgnoreCase(parts[1])) {
LOGGER.warn("Conformance suite tunnel rejected request: " + requestLine);
client.getOutputStream().write("HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n".getBytes(StandardCharsets.US_ASCII));
return;
}
Socket target = track(new Socket(targetHost, targetPort));
try (target) {
client.getOutputStream().write("HTTP/1.1 200 Connection Established\r\n\r\n".getBytes(StandardCharsets.US_ASCII));
client.getOutputStream().flush();
Future<?> response = executor.submit(() -> pipe(target, client));
pipe(client, target);
// Both sockets are closed only once the response is forwarded as well, as closing them while the
// suite is still answering would cut off its response
response.get();
} finally {
sockets.remove(target);
}
} catch (IOException | ExecutionException e) {
LOGGER.debug("Conformance suite tunnel connection closed: " + e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
sockets.remove(client);
}
}

/**
* Keeps the socket for {@link #close()}, as closing the listening socket does not close the accepted connections,
* and a worker blocked reading a socket does not stop on an interrupt. A socket opened while the tunnel closes is
* closed right away.
*/
private Socket track(Socket socket) {
sockets.add(socket);
if (closed) {
closeQuietly(socket);
}
return socket;
}

// Reads the request line and headers of the CONNECT request, which ends with an empty line
private static String readRequestHeader(InputStream input) throws IOException {
ByteArrayOutputStream header = new ByteArrayOutputStream();
int matched = 0;
while (matched < 4) {
int b = input.read();
if (b < 0 || header.size() >= MAX_REQUEST_HEADER_BYTES) {
throw new IOException("Incomplete proxy request");
}
header.write(b);
matched = (b == (matched % 2 == 0 ? '\r' : '\n')) ? matched + 1 : (b == '\r' ? 1 : 0);
}
String text = header.toString(StandardCharsets.US_ASCII);
return text.substring(0, text.indexOf("\r\n"));
}

// Copies one direction of the tunnel until its source closes, then closes the output of the other side, which the
// other direction still uses for the answer. On an error both sockets are closed, as the other direction would
// otherwise wait for data that never comes.
static void pipe(Socket from, Socket to) {
try {
InputStream input = from.getInputStream();
OutputStream output = to.getOutputStream();
byte[] buffer = new byte[8192];
for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) {
output.write(buffer, 0, read);
output.flush();
}
to.shutdownOutput();
} catch (IOException e) {
LOGGER.debug("Conformance suite tunnel forwarding failed: " + e.getMessage());
closeQuietly(from);
closeQuietly(to);
}
}

private static void closeQuietly(Socket socket) {
try {
socket.close();
} catch (IOException e) {
// closing anyway
}
}

@Override
public void close() {
closed = true;
try {
serverSocket.close();
} catch (IOException e) {
// closing anyway
}
// unblocks the workers reading these sockets, so they exit
sockets.forEach(SuiteTunnel::closeQuietly);
executor.shutdownNow();
}
}
Loading
Loading