diff --git a/src/main/java/io/axoniq/axonserver/connector/AxonServerConnectionFactory.java b/src/main/java/io/axoniq/axonserver/connector/AxonServerConnectionFactory.java index ab77d3b2..286951d7 100644 --- a/src/main/java/io/axoniq/axonserver/connector/AxonServerConnectionFactory.java +++ b/src/main/java/io/axoniq/axonserver/connector/AxonServerConnectionFactory.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2023. AxonIQ + * Copyright (c) 2020-2026. AxonIQ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -36,12 +36,14 @@ import io.grpc.Metadata; import io.grpc.MethodDescriptor; import io.grpc.Status; +import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext; import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -59,6 +61,7 @@ import java.util.function.UnaryOperator; import static io.axoniq.axonserver.connector.impl.ObjectUtils.randomHex; +import static java.util.Objects.requireNonNull; /** * The component which manages all the connections which an Axon client can establish with an Axon Server instance. Does @@ -380,6 +383,9 @@ public Builder token(String token) { /** * Configures the use of Transport Layer Security (TLS) using the default settings from the JVM. *
+ * Invoking this or any other transport security method replaces the transport security configuration of + * earlier invocations. + *
* Defaults to not using TLS. * * @return this builder for further configuration @@ -393,6 +399,9 @@ public Builder useTransportSecurity() { /** * Configures the use of Transport Layer Security (TLS) using the settings from the given {@code sslContext}. *
+ * Invoking this or any other transport security method replaces the transport security configuration of + * earlier invocations. + *
* Defaults to not using TLS. * * @param sslContext The context defining TLS parameters @@ -404,6 +413,100 @@ public Builder useTransportSecurity(SslContext sslContext) { return this; } + /** + * Configures the use of Transport Layer Security (TLS), verifying the server's certificate against the + * certificates in the given {@code trustedCertificates} file. Use this when Axon Server (or a proxy in front + * of it) uses a certificate that is not signed by a Certificate Authority trusted by the JVM by default. + *
+ * The given file must be in PEM format and is read once, when this method is invoked. A rotated certificate + * file is not picked up automatically; it requires reconfiguring the factory. + *
+ * Invoking this or any other transport security method replaces the transport security configuration of + * earlier invocations. + *
+ * Defaults to not using TLS. + * + * @param trustedCertificates A PEM file containing the CA certificates to verify the server's certificate + * against + * @return this builder for further configuration + * @see #useMutualTransportSecurity(File, File, File) + */ + public Builder useTransportSecurity(File trustedCertificates) { + return useTransportSecurity(buildSslContext(null, null, trustedCertificates)); + } + + /** + * Configures the use of mutual Transport Layer Security (mTLS), presenting the given client certificate to + * the server during the TLS handshake. The server's certificate is verified using the JVM's default trusted + * Certificate Authorities. + *
+ * The given files must be in PEM format, with the private key in PKCS#8 format. They are read once, when + * this method is invoked. Rotated certificate files are not picked up automatically; they require + * reconfiguring the factory. + *
+ * Invoking this or any other transport security method replaces the transport security configuration of + * earlier invocations. + *
+ * Defaults to not using TLS. + * + * @param clientCertificateChain A PEM file containing the client's certificate chain + * @param clientPrivateKey A PEM file containing the client's PKCS#8 private key + * @return this builder for further configuration + * @see #useMutualTransportSecurity(File, File, File) + */ + public Builder useMutualTransportSecurity(File clientCertificateChain, File clientPrivateKey) { + return useMutualTransportSecurity(clientCertificateChain, clientPrivateKey, null); + } + + /** + * Configures the use of mutual Transport Layer Security (mTLS), presenting the given client certificate to + * the server during the TLS handshake. The server's certificate is verified against the certificates in the + * given {@code trustedCertificates} file, or against the JVM's default trusted Certificate Authorities when + * {@code trustedCertificates} is {@code null}. + *
+ * The given files must be in PEM format, with the private key in PKCS#8 format. They are read once, when + * this method is invoked. Rotated certificate files are not picked up automatically; they require + * reconfiguring the factory. + *
+ * Invoking this or any other transport security method replaces the transport security configuration of + * earlier invocations. + *
+ * Defaults to not using TLS.
+ *
+ * @param clientCertificateChain A PEM file containing the client's certificate chain
+ * @param clientPrivateKey A PEM file containing the client's PKCS#8 private key
+ * @param trustedCertificates A PEM file containing the CA certificates to verify the server's certificate
+ * against, or {@code null} to use the JVM's default trusted Certificate
+ * Authorities
+ * @return this builder for further configuration
+ */
+ public Builder useMutualTransportSecurity(File clientCertificateChain,
+ File clientPrivateKey,
+ File trustedCertificates) {
+ requireNonNull(clientCertificateChain, "clientCertificateChain may not be null");
+ requireNonNull(clientPrivateKey, "clientPrivateKey may not be null");
+ return useTransportSecurity(buildSslContext(clientCertificateChain, clientPrivateKey,
+ trustedCertificates));
+ }
+
+ private static SslContext buildSslContext(File clientCertificateChain,
+ File clientPrivateKey,
+ File trustedCertificates) {
+ SslContextBuilder sslContextBuilder = GrpcSslContexts.forClient();
+ if (clientCertificateChain != null) {
+ sslContextBuilder.keyManager(clientCertificateChain, clientPrivateKey);
+ }
+ if (trustedCertificates != null) {
+ sslContextBuilder.trustManager(trustedCertificates);
+ }
+ try {
+ return sslContextBuilder.build();
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Could not create an SSL context from the given certificate files",
+ e);
+ }
+ }
+
/**
* Indicates whether the connector should always reconnect via the Routing Servers. When {@code true} (default),
* the connector will contact the Routing Servers for a new destination each time a connection is dropped. When
diff --git a/src/test/java/io/axoniq/axonserver/connector/MutualTlsConnectionTest.java b/src/test/java/io/axoniq/axonserver/connector/MutualTlsConnectionTest.java
new file mode 100644
index 00000000..ead3879f
--- /dev/null
+++ b/src/test/java/io/axoniq/axonserver/connector/MutualTlsConnectionTest.java
@@ -0,0 +1,180 @@
+/*
+ * Copyright (c) 2026. AxonIQ
+ *
+ * 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 io.axoniq.axonserver.connector;
+
+import io.axoniq.axonserver.connector.impl.ServerAddress;
+import io.axoniq.axonserver.grpc.control.ClientIdentification;
+import io.axoniq.axonserver.grpc.control.PlatformInboundInstruction;
+import io.axoniq.axonserver.grpc.control.PlatformInfo;
+import io.axoniq.axonserver.grpc.control.PlatformOutboundInstruction;
+import io.axoniq.axonserver.grpc.control.PlatformServiceGrpc;
+import io.grpc.Server;
+import io.grpc.TlsServerCredentials;
+import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
+import io.grpc.stub.StreamObserver;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.util.concurrent.TimeUnit;
+import java.util.function.UnaryOperator;
+
+import static io.axoniq.axonserver.connector.testutils.AssertUtils.assertWithin;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Test class validating the (mutual) TLS configuration options of the {@link AxonServerConnectionFactory}, against a
+ * stub PlatformService endpoint secured with TLS.
+ *
+ * @author Allard Buijze
+ */
+class MutualTlsConnectionTest {
+
+ private Server server;
+ private int serverPort;
+ private AxonServerConnectionFactory connectionFactory;
+
+ @AfterEach
+ void tearDown() {
+ if (connectionFactory != null) {
+ connectionFactory.shutdown();
+ }
+ if (server != null) {
+ server.shutdownNow();
+ }
+ }
+
+ @Test
+ void connectsWithClientCertificateWhenServerRequiresClientAuth() throws Exception {
+ startServer(TlsServerCredentials.ClientAuth.REQUIRE);
+
+ AxonServerConnection connection = connect(
+ factory -> factory.useMutualTransportSecurity(tlsFile("client.crt"),
+ tlsFile("client.key"),
+ tlsFile("ca.crt"))
+ );
+
+ assertWithin(10, TimeUnit.SECONDS, () -> assertTrue(connection.isConnected()));
+ }
+
+ @Test
+ void failsToConnectWithoutClientCertificateWhenServerRequiresClientAuth() throws Exception {
+ startServer(TlsServerCredentials.ClientAuth.REQUIRE);
+
+ AxonServerConnection connection = connect(
+ factory -> factory.useTransportSecurity(tlsFile("ca.crt"))
+ );
+
+ assertWithin(10, TimeUnit.SECONDS, () -> assertTrue(connection.isConnectionFailed()));
+ assertFalse(connection.isConnected());
+ }
+
+ @Test
+ void connectsWithTrustedCertificatesOnlyWhenServerDoesNotRequireClientAuth() throws Exception {
+ startServer(TlsServerCredentials.ClientAuth.NONE);
+
+ AxonServerConnection connection = connect(
+ factory -> factory.useTransportSecurity(tlsFile("ca.crt"))
+ );
+
+ assertWithin(10, TimeUnit.SECONDS, () -> assertTrue(connection.isConnected()));
+ }
+
+ @Test
+ void mutualTransportSecurityRejectsMissingCertificateOrKey() {
+ AxonServerConnectionFactory.Builder builder = AxonServerConnectionFactory.forClient("mtls-test");
+
+ assertThrows(NullPointerException.class,
+ () -> builder.useMutualTransportSecurity(null, tlsFile("client.key")));
+ assertThrows(NullPointerException.class,
+ () -> builder.useMutualTransportSecurity(tlsFile("client.crt"), null));
+ assertThrows(IllegalArgumentException.class,
+ () -> builder.useMutualTransportSecurity(new File("does-not-exist.crt"),
+ new File("does-not-exist.key")));
+ }
+
+ private void startServer(TlsServerCredentials.ClientAuth clientAuth) throws Exception {
+ TlsServerCredentials.Builder credentials = TlsServerCredentials.newBuilder()
+ .keyManager(tlsFile("server.crt"),
+ tlsFile("server.key"))
+ .clientAuth(clientAuth);
+ if (clientAuth != TlsServerCredentials.ClientAuth.NONE) {
+ credentials.trustManager(tlsFile("ca.crt"));
+ }
+ server = NettyServerBuilder.forPort(freePort(), credentials.build())
+ .addService(new StubPlatformService())
+ .build()
+ .start();
+ }
+
+ private AxonServerConnection connect(UnaryOperator