Repository: EventStore/EventStoreDB-Client-Java
Pull request #266 (2024-03-01 11:49:24)

DEV-299 - Support providing an x.509 certificate for user authentication
Added: new connection settings to provide an x.509 certificate for user authentication.

linear[bot]: <p><a href="https://linear.app/eventstore/issue/DEV-299/support-user-x509-authentication-on-the-java-client">DEV-299 Support user x.509 authentication on the Java client</a></p>

Patch:
diff --git a/.github/workflows/pull-requests.yml b/.github/workflows/pull-requests.yml
index ebd9b68..335449a 100644
--- a/.github/workflows/pull-requests.yml
+++ b/.github/workflows/pull-requests.yml
@@ -42,4 +42,19 @@ jobs:
 
     uses: ./.github/workflows/tests.yml
     with:
-      esdb_version: ${{ matrix.version }}
\ No newline at end of file
+      esdb_version: ${{ matrix.version }}
+
+  plugins-tests:
+    needs: build
+    name: Plugins Tests
+
+    strategy:
+      fail-fast: false
+      matrix:
+        version: [24.2.0-jammy]
+
+    uses: ./.github/workflows/plugins-tests.yml
+    with:
+      esdb_version: ${{ matrix.version }}
+
+    secrets: inherit
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 5515fd5..74e9168 100644
--- a/.gitignore
+++ b/.gitignore
@@ -171,5 +171,5 @@ fabric.properties
 # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
 !gradle-wrapper.jar
 
-# Generated certs
+# Generated certificates
 certs/
diff --git a/db-client-java/build.gradle b/db-client-java/build.gradle
index 086bdfd..4cd5a5e 100644
--- a/db-client-java/build.gradle
+++ b/db-client-java/build.gradle
@@ -47,6 +47,8 @@ dependencies {
     implementation "io.grpc:grpc-protobuf:${grpcVersion}"
     implementation "com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}"
     implementation 'org.slf4j:slf4j-api:2.0.9'
+    implementation "org.bouncycastle:bcprov-jdk18on:1.77"
+    implementation "org.bouncycastle:bcpkix-jdk18on:1.77"
 
     testImplementation "org.junit.jupiter:junit-jupiter-api:${junitVersion}"
     testImplementation "org.junit.jupiter:junit-jupiter-params:${junitVersion}"
@@ -78,6 +80,11 @@ tasks.register("generateCertificates", Exec) {
     commandLine 'docker', 'compose', '--file', '../configure-tls-for-tests.yml', 'up'
 }
 
+tasks.register("generateUserCertificates", Exec) {
+    description = "Generates X.509 certificates for Plugins tests"
+    commandLine 'docker', 'compose', '--file', '../configure-user-certs-for-tests.yml', 'up'
+}
+
 tasks.register("startDockerCompose", Exec) {
     description = "Starts ESDB cluster"
     commandLine 'docker', 'compose', '--file', '../docker-compose.yml', 'up', '-d'
@@ -125,6 +132,17 @@ tasks.register("clusterTests", Test) {
     }
 }
 
+tasks.register("pluginsTests", Test) {
+    dependsOn generateCertificates
+    dependsOn generateUserCertificates
+
+    environment "SECURE", "true"
+
+    useJUnitPlatform {
+        include("**/PluginsTests.class")
+    }
+}
+
 tasks.register("ci", Test) {
     useJUnitPlatform()
 }
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/AbstractCreatePersistentSubscription.java b/db-client-java/src/main/java/com/eventstore/dbclient/AbstractCreatePersistentSubscription.java
index 1b483ce..fffd929 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/AbstractCreatePersistentSubscription.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/AbstractCreatePersistentSubscription.java
@@ -11,11 +11,11 @@ abstract class AbstractCreatePersistentSubscription<TPos, TSettings extends Pers
     private final GrpcClient client;
     private final String group;
     private final TSettings settings;
-    private final OptionsBase options;
+    private final OptionsBase<?> options;
     private static final Logger logger = LoggerFactory.getLogger(AbstractCreatePersistentSubscription.class);
 
     public AbstractCreatePersistentSubscription(GrpcClient client, String group,
-                                                TSettings settings, OptionsBase options) {
+                                                TSettings settings, OptionsBase<?> options) {
         this.client = client;
         this.group = group;
         this.settings = settings;
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/AbstractRead.java b/db-client-java/src/main/java/com/eventstore/dbclient/AbstractRead.java
index 8e358e7..3f9df65 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/AbstractRead.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/AbstractRead.java
@@ -16,9 +16,9 @@ abstract class AbstractRead implements Publisher<ReadMessage> {
     protected static final StreamsOuterClass.ReadReq.Options.Builder defaultReadOptions;
 
     private final GrpcClient client;
-    private final OptionsBase options;
+    private final OptionsBase<?> options;
 
-    protected AbstractRead(GrpcClient client, OptionsBase options) {
+    protected AbstractRead(GrpcClient client, OptionsBase<?> options) {
         this.client = client;
         this.options = options;
     }
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/AbstractRegularSubscription.java b/db-client-java/src/main/java/com/eventstore/dbclient/AbstractRegularSubscription.java
index 0ad3194..e0b47c9 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/AbstractRegularSubscription.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/AbstractRegularSubscription.java
@@ -22,9 +22,9 @@ abstract class AbstractRegularSubscription {
     protected SubscriptionListener listener;
     protected Checkpointer checkpointer = null;
     private final GrpcClient client;
-    private final OptionsBase options;
+    private final OptionsBase<?> options;
 
-    protected AbstractRegularSubscription(GrpcClient client, OptionsBase options) {
+    protected AbstractRegularSubscription(GrpcClient client, OptionsBase<?> options) {
         this.client = client;
         this.options = options;
     }
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/AbstractSubscribePersistentSubscription.java b/db-client-java/src/main/java/com/eventstore/dbclient/AbstractSubscribePersistentSubscription.java
index e6b2ecb..8df5f63 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/AbstractSubscribePersistentSubscription.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/AbstractSubscribePersistentSubscription.java
@@ -14,7 +14,7 @@ import java.util.concurrent.CompletableFuture;
 
 abstract class AbstractSubscribePersistentSubscription {
     protected static final Persistent.ReadReq.Options.Builder defaultReadOptions;
-    private final GrpcClient connection;
+    private final GrpcClient client;
     private final String group;
     private final PersistentSubscriptionListener listener;
     private final SubscribePersistentSubscriptionOptions options;
@@ -25,10 +25,10 @@ abstract class AbstractSubscribePersistentSubscription {
                         .setStructured(Shared.Empty.getDefaultInstance()));
     }
 
-    public AbstractSubscribePersistentSubscription(GrpcClient connection, String group,
+    public AbstractSubscribePersistentSubscription(GrpcClient client, String group,
                                                    SubscribePersistentSubscriptionOptions options,
                                                    PersistentSubscriptionListener listener) {
-        this.connection = connection;
+        this.client = client;
         this.group = group;
         this.options = options;
         this.listener = listener;
@@ -37,9 +37,9 @@ abstract class AbstractSubscribePersistentSubscription {
     protected abstract Persistent.ReadReq.Options.Builder createOptions();
 
     public CompletableFuture<PersistentSubscription> execute() {
-        return this.connection.runWithArgs(args -> {
+        return this.client.runWithArgs(args -> {
             PersistentSubscriptionsGrpc.PersistentSubscriptionsStub client =
-                    GrpcUtils.configureStub(PersistentSubscriptionsGrpc.newStub(args.getChannel()), this.connection.getSettings(), this.options);
+                    GrpcUtils.configureStub(PersistentSubscriptionsGrpc.newStub(args.getChannel()), this.client.getSettings(), this.options);
 
             final CompletableFuture<PersistentSubscription> result = new CompletableFuture<>();
 
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/AbstractUpdatePersistentSubscription.java b/db-client-java/src/main/java/com/eventstore/dbclient/AbstractUpdatePersistentSubscription.java
index c2746ca..372e7b0 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/AbstractUpdatePersistentSubscription.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/AbstractUpdatePersistentSubscription.java
@@ -8,15 +8,15 @@ import org.slf4j.LoggerFactory;
 import java.util.concurrent.CompletableFuture;
 
 abstract class AbstractUpdatePersistentSubscription {
-    private final GrpcClient connection;
+    private final GrpcClient client;
     private final String group;
     private final PersistentSubscriptionSettings settings;
-    private final OptionsBase options;
+    private final OptionsBase<?> options;
     private static final Logger logger = LoggerFactory.getLogger(AbstractUpdatePersistentSubscription.class);
 
-    public AbstractUpdatePersistentSubscription(GrpcClient connection, String group,
-                                                PersistentSubscriptionSettings settings, OptionsBase options) {
-        this.connection = connection;
+    public AbstractUpdatePersistentSubscription(GrpcClient client, String group,
+                                                PersistentSubscriptionSettings settings, OptionsBase<?> options) {
+        this.client = client;
         this.group = group;
         this.settings = settings;
         this.options = options;
@@ -30,10 +30,10 @@ abstract class AbstractUpdatePersistentSubscription {
 
     @SuppressWarnings("unchecked")
     public CompletableFuture execute() {
-        return this.connection.runWithArgs(args -> {
+        return this.client.runWithArgs(args -> {
             CompletableFuture result = new CompletableFuture();
             PersistentSubscriptionsGrpc.PersistentSubscriptionsStub client =
-                    GrpcUtils.configureStub(PersistentSubscriptionsGrpc.newStub(args.getChannel()), this.connection.getSettings(), this.options);
+                    GrpcUtils.configureStub(PersistentSubscriptionsGrpc.newStub(args.getChannel()), this.client.getSettings(), this.options);
             Persistent.UpdateReq.Settings.Builder settingsBuilder = createSettings();
 
             settingsBuilder
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/ClientCertificate.java b/db-client-java/src/main/java/com/eventstore/dbclient/ClientCertificate.java
new file mode 100644
index 0000000..d2f834f
--- /dev/null
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/ClientCertificate.java
@@ -0,0 +1,50 @@
+package com.eventstore.dbclient;
+
+import java.util.Objects;
+
+/**
+ * Holds a certificate and key for authenticated requests.
+ */
+public final class ClientCertificate {
+    private final String certFile;
+    private final String keyFile;
+
+    public ClientCertificate(String certFile, String keyFile) {
+        this.certFile = certFile;
+        this.keyFile = keyFile;
+    }
+
+    /**
+     * Certificate for user authentication.
+     */
+    public String getCertFile() {
+        return certFile;
+    }
+
+    /**
+     * Certificate key for user authentication.
+     */
+    public String getKeyFile() {
+        return keyFile;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (obj == null || getClass() != obj.getClass()) {
+            return false;
+        }
+
+        ClientCertificate other = (ClientCertificate) obj;
+
+        return Objects.equals(certFile, other.certFile)
+                && Objects.equals(keyFile, other.keyFile);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(certFile, keyFile);
+    }
+}
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/ClusterDiscovery.java b/db-client-java/src/main/java/com/eventstore/dbclient/ClusterDiscovery.java
index 35f330e..9abd66e 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/ClusterDiscovery.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/ClusterDiscovery.java
@@ -78,4 +78,4 @@ class ClusterDiscovery implements Discovery {
 
         throw new NoClusterNodeFoundException();
     }
-}
+}
\ No newline at end of file
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/ConnectionService.java b/db-client-java/src/main/java/com/eventstore/dbclient/ConnectionService.java
index 20417e2..83c06ab 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/ConnectionService.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/ConnectionService.java
@@ -13,7 +13,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
 /**
  * Main gRPC connection management service.
  */
-class ConnectionService implements Runnable, MsgHandler {
+class ConnectionService implements Runnable {
     private final Logger logger = LoggerFactory.getLogger(ConnectionService.class);
     private final GrpcClient client;
     private final AtomicBoolean closed;
@@ -99,7 +99,6 @@ class ConnectionService implements Runnable, MsgHandler {
         this.forceExit(null);
     }
 
-    @Override
     public void createChannel(UUID previousId, InetSocketAddress candidate) {
         if (this.closed.get()) {
             logger.warn("Channel creation request ignored, the connection to endpoint [{}] is already closed", this.connection.getLastConnectedEndpoint());
@@ -154,7 +153,6 @@ class ConnectionService implements Runnable, MsgHandler {
         }
     }
 
-    @Override
     public void process(RunWorkItem args) {
         if (this.closed.get()) {
             logger.warn("Receive a command request but the connection to endpoint [{}] is already closed", this.connection.getLastConnectedEndpoint());
@@ -183,7 +181,6 @@ class ConnectionService implements Runnable, MsgHandler {
         args.getItem().accept(workArgs, null);
     }
 
-    @Override
     public void shutdown(Shutdown args) {
         if (this.closed.get()) {
             args.complete();
@@ -198,4 +195,4 @@ class ConnectionService implements Runnable, MsgHandler {
         args.complete();
         throw new ConnectionShutdownException();
     }
-}
+}
\ No newline at end of file
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/ConnectionSettingsBuilder.java b/db-client-java/src/main/java/com/eventstore/dbclient/ConnectionSettingsBuilder.java
index 2578fed..49571de 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/ConnectionSettingsBuilder.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/ConnectionSettingsBuilder.java
@@ -24,6 +24,7 @@ public class ConnectionSettingsBuilder {
     private boolean _tls = true;
     private boolean _tlsVerifyCert = true;
     private UserCredentials _defaultCredentials;
+    private ClientCertificate _defaultClientCertificate;
     private LinkedList<InetSocketAddress> _hosts = new LinkedList<>();
     private long _keepAliveTimeout = Consts.DEFAULT_KEEP_ALIVE_TIMEOUT_IN_MS;
     private long _keepAliveInterval = Consts.DEFAULT_KEEP_ALIVE_INTERVAL_IN_MS;
@@ -47,6 +48,7 @@ public class ConnectionSettingsBuilder {
                 _tls,
                 _tlsVerifyCert,
                 _defaultCredentials,
+                _defaultClientCertificate,
                 _hosts.toArray(new InetSocketAddress[0]),
                 _keepAliveTimeout,
                 _keepAliveInterval,
@@ -119,6 +121,30 @@ public class ConnectionSettingsBuilder {
         return this;
     }
 
+    /**
+     * Default credentials used to authenticate requests.
+     */
+    public ConnectionSettingsBuilder defaultCredentials(UserCredentials defaultCredentials) {
+        this._defaultCredentials = defaultCredentials;
+        return this;
+    }
+
+    /**
+     * Client certificate used for user authentication.
+     */
+    public ConnectionSettingsBuilder defaultClientCertificate(String clientCertFile, String clientKeyFile) {
+        this._defaultClientCertificate = new ClientCertificate(clientCertFile, clientKeyFile);
+        return this;
+    }
+
+    /**
+     * Client certificate used for user authentication.
+     */
+    public ConnectionSettingsBuilder defaultClientCertificate(ClientCertificate defaultClientCertificate) {
+        this._defaultClientCertificate = defaultClientCertificate;
+        return this;
+    }
+
     /**
      * Adds an endpoint the client will use to connect.
      */
@@ -156,7 +182,7 @@ public class ConnectionSettingsBuilder {
     public ConnectionSettingsBuilder keepAliveInterval(long value) {
         if (value >= 0 && value < Consts.DEFAULT_KEEP_ALIVE_INTERVAL_IN_MS) {
             logger.warn("Specified keepAliveInterval of {} is less than recommended {}", value, Consts.DEFAULT_KEEP_ALIVE_INTERVAL_IN_MS);
-        } 
+        }
 
         if (value == -1)
             value = Long.MAX_VALUE;
@@ -194,7 +220,7 @@ public class ConnectionSettingsBuilder {
     }
 
     void parseGossipSeed(String host) {
-        String[] hostParts =  host.split(":");
+        String[] hostParts = host.split(":");
 
         switch (hostParts.length) {
             case 1:
@@ -251,6 +277,8 @@ public class ConnectionSettingsBuilder {
         if (url.getQuery() == null)
             return builder.buildConnectionSettings();
 
+        String userCertFile = null;
+        String userKeyFile = null;
         for (String param : url.getQuery().split("&")) {
             String[] entry = param.split("=");
 
@@ -370,7 +398,7 @@ public class ConnectionSettingsBuilder {
 
                         builder._keepAliveInterval = parsedValue;
                     } catch (NumberFormatException e) {
-                        invalidParamFormat(entry[0], value);;
+                        invalidParamFormat(entry[0], value);
                     }
                     break;
 
@@ -394,12 +422,32 @@ public class ConnectionSettingsBuilder {
                     builder._tlsCaFile = entry[1];
                     break;
 
+                case "usercertfile":
+                    if (entry[1].isEmpty())
+                        invalidParamFormat(entry[0], entry[1]);
+
+                    userCertFile = entry[1];
+                    break;
+
+                case "userkeyfile":
+                    if (entry[1].isEmpty())
+                        invalidParamFormat(entry[0], entry[1]);
+
+                    userKeyFile = entry[1];
+                    break;
+
                 default:
                     logger.warn(String.format("Unknown setting '%s' is ignored", entry[0]));
                     break;
             }
         }
 
+        if (userCertFile != null ^ userKeyFile != null)
+            throw new RuntimeException("Invalid user certificate settings. Both 'userCertFile' and 'userKeyFile' must be provided.");
+
+        if (userCertFile != null)
+            builder.defaultClientCertificate(userCertFile, userKeyFile);
+
         return builder.buildConnectionSettings();
     }
 
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/ConnectionState.java b/db-client-java/src/main/java/com/eventstore/dbclient/ConnectionState.java
index a7cc09d..6c71027 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/ConnectionState.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/ConnectionState.java
@@ -35,6 +35,11 @@ class ConnectionState {
             try {
                 SslContextBuilder sslContextBuilder = GrpcSslContexts.forClient();
 
+                if (settings.getDefaultClientCertificate() != null)
+                    sslContextBuilder.keyManager(
+                            new File(settings.getDefaultClientCertificate().getCertFile()),
+                            new File(settings.getDefaultClientCertificate().getKeyFile()));
+
                 if (settings.getTlsCaFile() != null)
                     sslContextBuilder.trustManager(new File(settings.getTlsCaFile()));
 
@@ -121,4 +126,4 @@ class ConnectionState {
         this.previous = null;
         this.confirmedChannel = false;
     }
-}
+}
\ No newline at end of file
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/CreateChannel.java b/db-client-java/src/main/java/com/eventstore/dbclient/CreateChannel.java
index 2fd3e49..e1576c3 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/CreateChannel.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/CreateChannel.java
@@ -26,7 +26,7 @@ class CreateChannel implements Msg {
     }
 
     @Override
-    public void accept(MsgHandler handler) {
-        handler.createChannel(this.previousId, this.channel);
+    public void accept(ConnectionService connectionService) {
+        connectionService.createChannel(this.previousId, this.channel);
     }
-}
+}
\ No newline at end of file
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/Discovery.java b/db-client-java/src/main/java/com/eventstore/dbclient/Discovery.java
index 23e40a2..c650104 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/Discovery.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/Discovery.java
@@ -4,4 +4,4 @@ import java.util.concurrent.CompletableFuture;
 
 public interface Discovery {
     CompletableFuture<Void> run(ConnectionState state);
-}
+}
\ No newline at end of file
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/EventStoreDBClientBase.java b/db-client-java/src/main/java/com/eventstore/dbclient/EventStoreDBClientBase.java
index 7d59699..6fc61cd 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/EventStoreDBClientBase.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/EventStoreDBClientBase.java
@@ -1,8 +1,10 @@
 package com.eventstore.dbclient;
 
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.security.Security;
 import java.util.Optional;
 import java.util.UUID;
 import java.util.concurrent.CompletableFuture;
@@ -22,6 +24,8 @@ public class EventStoreDBClientBase {
             discovery = new ClusterDiscovery(settings);
         }
 
+        // Required to instruct Netty to use BouncyCastle for TLS
+        Security.addProvider(new BouncyCastleProvider());
         ConnectionService service = new ConnectionService(settings, discovery);
         this.client = service.getHandle();
 
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/EventStoreDBClientSettings.java b/db-client-java/src/main/java/com/eventstore/dbclient/EventStoreDBClientSettings.java
index fc3ba3c..4e198b2 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/EventStoreDBClientSettings.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/EventStoreDBClientSettings.java
@@ -32,6 +32,7 @@ public class EventStoreDBClientSettings {
     private final boolean tls;
     private final boolean tlsVerifyCert;
     private final UserCredentials defaultCredentials;
+    private final ClientCertificate defaultClientCertificate;
     private final InetSocketAddress[] hosts;
     private final long keepAliveTimeout;
     private final long keepAliveInterval;
@@ -92,6 +93,7 @@ public class EventStoreDBClientSettings {
 
     /**
      * Default credentials used to authenticate requests.
+     * User credentials take precedence over any configured {@link ClientCertificate}.
      * @see UserCredentials
      * @return default credentials null if not defined
      */
@@ -99,6 +101,16 @@ public class EventStoreDBClientSettings {
         return defaultCredentials;
     }
 
+    /**
+     * Default certificate for user authentication.
+     * If any {@link UserCredentials} are configured, the server will ignore the user certificate.
+     * @see ClientCertificate
+     * @return user certificate, otherwise null.
+     */
+    public ClientCertificate getDefaultClientCertificate() {
+        return defaultClientCertificate;
+    }
+
     /**
      * The list of endpoints that the client uses to connect.
      * @return hosts to connect to.
@@ -157,6 +169,7 @@ public class EventStoreDBClientSettings {
             boolean tls,
             boolean tlsVerifyCert,
             UserCredentials defaultCredentials,
+            ClientCertificate defaultClientCertificate,
             InetSocketAddress[] hosts,
             long keepAliveTimeout,
             long keepAliveInterval,
@@ -172,6 +185,7 @@ public class EventStoreDBClientSettings {
         this.tls = tls;
         this.tlsVerifyCert = tlsVerifyCert;
         this.defaultCredentials = defaultCredentials;
+        this.defaultClientCertificate = defaultClientCertificate;
         this.hosts = hosts;
         this.keepAliveTimeout = keepAliveTimeout;
         this.keepAliveInterval = keepAliveInterval;
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/GrpcClient.java b/db-client-java/src/main/java/com/eventstore/dbclient/GrpcClient.java
index d660ff1..eeba12a 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/GrpcClient.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/GrpcClient.java
@@ -115,4 +115,4 @@ class GrpcClient {
     public EventStoreDBClientSettings getSettings() {
         return this.settings;
     }
-}
+}
\ No newline at end of file
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/GrpcUtils.java b/db-client-java/src/main/java/com/eventstore/dbclient/GrpcUtils.java
index db8e570..a61d207 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/GrpcUtils.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/GrpcUtils.java
@@ -124,7 +124,7 @@ final class GrpcUtils {
             long deadlineInMs = 10_000;
 
             if (forceDeadlineInMs != null) {
-               deadlineInMs = forceDeadlineInMs;
+                deadlineInMs = forceDeadlineInMs;
             } else if (options.getDeadline() != null) {
                 deadlineInMs = options.getDeadline();
             } else if (settings.getDefaultDeadline() != null) {
@@ -152,4 +152,4 @@ final class GrpcUtils {
 
         return finalStub.withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata.build()));
     }
-}
+}
\ No newline at end of file
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/Msg.java b/db-client-java/src/main/java/com/eventstore/dbclient/Msg.java
index e4eca36..8ddee15 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/Msg.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/Msg.java
@@ -1,5 +1,5 @@
 package com.eventstore.dbclient;
 
 interface Msg {
-    void accept(MsgHandler handler);
+    void accept(ConnectionService handler);
 }
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/MsgHandler.java b/db-client-java/src/main/java/com/eventstore/dbclient/MsgHandler.java
deleted file mode 100644
index cb74e8a..0000000
--- a/db-client-java/src/main/java/com/eventstore/dbclient/MsgHandler.java
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.eventstore.dbclient;
-
-import java.net.InetSocketAddress;
-import java.util.UUID;
-
-interface MsgHandler {
-    void createChannel(UUID previousId, InetSocketAddress endpoint);
-    void process(RunWorkItem args);
-    void shutdown(Shutdown args);
-}
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/OptionsBase.java b/db-client-java/src/main/java/com/eventstore/dbclient/OptionsBase.java
index 3550a97..08c0d31 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/OptionsBase.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/OptionsBase.java
@@ -24,7 +24,6 @@ class OptionsBase<T> {
 
     /**
      * Sets user credentials for the request
-
      * @param credentials
      * @see UserCredentials
      * @return updated options
@@ -32,12 +31,11 @@ class OptionsBase<T> {
     @SuppressWarnings("unchecked")
     public T authenticated(UserCredentials credentials) {
         this.credentials = credentials;
-        return (T)this;
+        return (T) this;
     }
 
     /**
      * Sets user credentials for the request
-
      * @param login
      * @param password
      * @return updated options
@@ -70,7 +68,7 @@ class OptionsBase<T> {
     @SuppressWarnings("unchecked")
     public T requiresLeader(boolean value) {
         this.requiresLeader = value;
-        return (T)this;
+        return (T) this;
     }
 
     /**
@@ -100,4 +98,4 @@ class OptionsBase<T> {
     UserCredentials getCredentials() {
         return this.credentials;
     }
-}
+}
\ No newline at end of file
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/RunWorkItem.java b/db-client-java/src/main/java/com/eventstore/dbclient/RunWorkItem.java
index c144bdd..0cdee86 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/RunWorkItem.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/RunWorkItem.java
@@ -16,6 +16,7 @@ class RunWorkItem implements Msg {
     public WorkItem getItem() {
         return item;
     }
+
     public void reportError(Exception e) {
         this.item.accept(null, e);
     }
@@ -26,7 +27,7 @@ class RunWorkItem implements Msg {
     }
 
     @Override
-    public void accept(MsgHandler handler) {
-        handler.process(this);
+    public void accept(ConnectionService connectionService) {
+        connectionService.process(this);
     }
-}
+}
\ No newline at end of file
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/ServerFeatures.java b/db-client-java/src/main/java/com/eventstore/dbclient/ServerFeatures.java
index 0717bb2..0fd42ec 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/ServerFeatures.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/ServerFeatures.java
@@ -137,4 +137,4 @@ class ServerFeatures {
             super(cause);
         }
     }
-}
+}
\ No newline at end of file
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/Shutdown.java b/db-client-java/src/main/java/com/eventstore/dbclient/Shutdown.java
index 08f9d7a..d63902c 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/Shutdown.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/Shutdown.java
@@ -19,7 +19,7 @@ class Shutdown implements Msg {
     }
 
     @Override
-    public void accept(MsgHandler handler) {
+    public void accept(ConnectionService handler) {
         handler.shutdown(this);
     }
 }
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/SingleNodeDiscovery.java b/db-client-java/src/main/java/com/eventstore/dbclient/SingleNodeDiscovery.java
index 49b6a91..3b86385 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/SingleNodeDiscovery.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/SingleNodeDiscovery.java
@@ -14,4 +14,4 @@ class SingleNodeDiscovery implements Discovery {
     public CompletableFuture<Void> run(ConnectionState state) {
         return CompletableFuture.runAsync(() -> state.connect(endpoint));
     }
-}
+}
\ No newline at end of file
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/WorkItemArgs.java b/db-client-java/src/main/java/com/eventstore/dbclient/WorkItemArgs.java
index b0cb003..41c0744 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/WorkItemArgs.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/WorkItemArgs.java
@@ -74,4 +74,4 @@ class WorkItemArgs {
             throw new RuntimeException(e);
         }
     }
-}
+}
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index 2330d7b..a9bb49d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,7 +10,7 @@ services:
     network_mode: none
 
   cert-gen:
-    image: eventstore/es-gencert-cli:1.0.2
+    image: ghcr.io/eventstore/es-gencert-cli:1.3.0
     entrypoint: bash
     user: "1000:1000"
     command: >
@@ -25,8 +25,8 @@ services:
     depends_on:
       - volumes-provisioner
 
-  esdb-node1:
-    image: ghcr.io/eventstore/eventstore:${CONTAINER_IMAGE_VERSION:-latest}
+  esdb-node1: &template
+    image: ${CONTAINER_REGISTRY:-ghcr.io}/${CONTAINER_IMAGE:-eventstore/eventstore}:${CONTAINER_IMAGE_VERSION:-latest}
     env_file:
       - vars.env
     environment:
@@ -47,7 +47,7 @@ services:
       - cert-gen
 
   esdb-node2:
-    image: ghcr.io/eventstore/eventstore:${CONTAINER_IMAGE_VERSION:-latest}
+    <<: *template
     env_file:
       - vars.env
     environment:
@@ -68,7 +68,7 @@ services:
       - cert-gen
 
   esdb-node3:
-    image: ghcr.io/eventstore/eventstore:${CONTAINER_IMAGE_VERSION:-latest}
+    <<: *template
     env_file:
       - vars.env
     environment:


Test patch:
diff --git a/.github/workflows/plugins-tests.yml b/.github/workflows/plugins-tests.yml
new file mode 100644
index 0000000..d37bc04
--- /dev/null
+++ b/.github/workflows/plugins-tests.yml
@@ -0,0 +1,111 @@
+name: enterprise plugins tests workflow
+
+on:
+  workflow_call:
+    inputs:
+      esdb_version:
+        required: true
+        type: string
+
+jobs:
+  single_node:
+    name: Single Node
+
+    strategy:
+      fail-fast: false
+      matrix:
+        test: [ Plugins ]
+
+    runs-on: ubuntu-latest
+    steps:
+      - name: Login to Cloudsmith
+        uses: docker/login-action@v3
+        with:
+          registry: docker.eventstore.com
+          username: ${{ secrets.CLOUDSMITH_CICD_USER }}
+          password: ${{ secrets.CLOUDSMITH_CICD_TOKEN }}
+
+      - uses: actions/checkout@v3
+
+      - name: Set up JDK 8
+        uses: actions/setup-java@v3
+        with:
+          java-version: '8'
+          distribution: 'temurin'
+
+      - name: Setup Gradle
+        uses: gradle/gradle-build-action@v2
+        with:
+          gradle-version: 7.4
+
+      - name: Generate certificates
+        run: |
+          docker compose --file configure-tls-for-tests.yml up
+          docker compose --file configure-user-certs-for-tests.yml up
+
+      - name: Execute Gradle build
+        run: ./gradlew ci --tests ${{ matrix.test }}Tests
+        env:
+          EVENTSTORE_DOCKER_REGISTRY_ENV: docker.eventstore.com
+          EVENTSTORE_DOCKER_IMAGE_ENV: eventstore-ee/eventstoredb-commercial
+          EVENTSTORE_DOCKER_TAG_ENV: ${{ inputs.esdb_version }}
+          SECURE: true
+
+      - uses: actions/upload-artifact@v3
+        if: failure()
+        with:
+          name: esdb_logs.tar.gz
+          path: db-client-java/esdb_logs.tar.gz
+          if-no-files-found: error
+
+  cluster:
+    name: Cluster
+
+    strategy:
+      fail-fast: false
+      matrix:
+        test: [ Plugins ]
+
+    runs-on: ubuntu-latest
+    steps:
+      - name: Login to Cloudsmith
+        uses: docker/login-action@v3
+        with:
+          registry: docker.eventstore.com
+          username: ${{ secrets.CLOUDSMITH_CICD_USER }}
+          password: ${{ secrets.CLOUDSMITH_CICD_TOKEN }}
+
+      - uses: actions/checkout@v3
+
+      - name: Enable plugins
+        run: echo "EventStore__Plugins__UserCertificates__Enabled=true" >> vars.env
+
+      - name: Set up cluster with Docker Compose
+        run: docker compose up -d
+        env:
+          CONTAINER_REGISTRY: docker.eventstore.com
+          CONTAINER_IMAGE: eventstore-ee/eventstoredb-commercial
+          CONTAINER_IMAGE_VERSION: ${{ inputs.esdb_version }}
+
+      - name: Generate user certificates
+        run: docker compose --file configure-user-certs-for-tests.yml up
+
+      - name: Set up JDK 8
+        uses: actions/setup-java@v3
+        with:
+          java-version: '8'
+          distribution: 'temurin'
+
+      - name: Setup Gradle
+        uses: gradle/gradle-build-action@v2
+        with:
+          gradle-version: 7.4
+
+      - name: Execute Gradle build
+        run: ./gradlew ci --tests ${{ matrix.test }}Tests
+        env:
+          SECURE: true
+          CLUSTER: true
+
+      - name: Shutdown cluster
+        run: docker compose down
\ No newline at end of file
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 5a21257..0ab02e6 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -14,7 +14,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        test: [Streams, PersistentSubscriptions, Expectations ]
+        test: [Streams, PersistentSubscriptions, Expectations]
 
     runs-on: ubuntu-latest
     steps:
@@ -56,7 +56,7 @@ jobs:
       - uses: actions/checkout@v3
 
       - name: Generate certificates
-        run: docker-compose --file configure-tls-for-tests.yml up
+        run: docker compose --file configure-tls-for-tests.yml up
 
       - name: Set up JDK 8
         uses: actions/setup-java@v3
@@ -94,7 +94,7 @@ jobs:
       - uses: actions/checkout@v3
 
       - name: Set up cluster with Docker Compose
-        run: docker-compose up -d
+        run: docker compose up -d
         env:
           CONTAINER_IMAGE_VERSION: ${{ inputs.esdb_version }}
 
@@ -117,4 +117,4 @@ jobs:
           SECURE: true
 
       - name: Shutdown cluster
-        run: docker-compose down
+        run: docker compose down
\ No newline at end of file
diff --git a/configure-tls-for-tests.yml b/configure-tls-for-tests.yml
index 62b8728..e345d1c 100644
--- a/configure-tls-for-tests.yml
+++ b/configure-tls-for-tests.yml
@@ -10,7 +10,7 @@ services:
     network_mode: "none"
 
   setup:
-    image: eventstore/es-gencert-cli:1.0.2
+    image: ghcr.io/eventstore/es-gencert-cli:1.3.0
     entrypoint: bash
     user: "1000:1000"
     command: >
diff --git a/configure-user-certs-for-tests.yml b/configure-user-certs-for-tests.yml
new file mode 100644
index 0000000..e08cdb6
--- /dev/null
+++ b/configure-user-certs-for-tests.yml
@@ -0,0 +1,25 @@
+version: '3.5'
+
+services:
+  volumes-provisioner:
+    image: "hasnat/volumes-provisioner"
+    environment:
+      PROVISION_DIRECTORIES: "1000:1000:0755:/tmp/certs"
+    volumes:
+      - "./certs:/tmp/certs"
+    network_mode: "none"
+
+  setup:
+    image: ghcr.io/eventstore/es-gencert-cli:1.3.0
+    entrypoint: bash
+    user: "1000:1000"
+    command: >
+      -c "mkdir -p ./certs && cd /certs
+      && es-gencert-cli create-user -username admin
+      && es-gencert-cli create-user -username invalid
+      && find . -type f -print0 | xargs -0 chmod 666"
+    container_name: setup
+    volumes:
+      - ./certs:/certs
+    depends_on:
+      - volumes-provisioner
diff --git a/db-client-java/src/test/java/com/eventstore/dbclient/DatabaseFactory.java b/db-client-java/src/test/java/com/eventstore/dbclient/DatabaseFactory.java
index 58d3049..6f4717a 100644
--- a/db-client-java/src/test/java/com/eventstore/dbclient/DatabaseFactory.java
+++ b/db-client-java/src/test/java/com/eventstore/dbclient/DatabaseFactory.java
@@ -7,27 +7,49 @@ import java.util.Optional;
 
 public class DatabaseFactory {
     public static Database spawn() {
-        String version = Optional.ofNullable(System.getenv("EVENTSTORE_DOCKER_TAG_ENV")).orElse("latest");
         boolean secure = Boolean.parseBoolean(Optional.ofNullable(System.getenv("SECURE")).orElse("false"));
         boolean cluster = Boolean.parseBoolean(Optional.ofNullable(System.getenv("CLUSTER")).orElse("false"));
 
         if (cluster)
             return new ExternallyCreatedCluster(secure);
 
-        return DockerContainerDatabase
-                .builder()
-                .version(version)
+        return singleNodeBuilder()
                 .secure(secure)
                 .build();
     }
 
+    public static Database spawnEnterpriseWithPluginsEnabled(String... pluginsToEnable) {
+        boolean secure = Boolean.parseBoolean(Optional.ofNullable(System.getenv("SECURE")).orElse("false"));
+        boolean cluster = Boolean.parseBoolean(Optional.ofNullable(System.getenv("CLUSTER")).orElse("false"));
+
+        if (cluster)
+            return new ExternallyCreatedCluster(secure);
+
+        DockerContainerDatabase.Builder builder = singleNodeBuilder();
+
+        for (String plugin : pluginsToEnable)
+            builder.env(String.format("EventStore__Plugins__%s__Enabled", plugin), "true");
+
+        return builder.secure(secure).build();
+    }
+
     public static Database spawnPopulatedDatabase() {
-        String version = Optional.ofNullable(System.getenv("EVENTSTORE_DOCKER_TAG_ENV")).orElse("latest");
+        return singleNodeBuilder()
+                .grpcTestDataImage()
+                .build();
+    }
 
+    private static DockerContainerDatabase.Builder singleNodeBuilder() {
         return DockerContainerDatabase
                 .builder()
-                .grpcTestDataImage()
-                .version(version)
-                .build();
+                .registry(Optional
+                        .ofNullable(System.getenv("EVENTSTORE_DOCKER_REGISTRY_ENV"))
+                        .orElse(DockerContainerDatabase.DEFAULT_REGISTRY))
+                .image(Optional
+                        .ofNullable(System.getenv("EVENTSTORE_DOCKER_IMAGE_ENV"))
+                        .orElse(DockerContainerDatabase.DEFAULT_IMAGE))
+                .version(Optional
+                        .ofNullable(System.getenv("EVENTSTORE_DOCKER_TAG_ENV"))
+                        .orElse(DockerContainerDatabase.DEFAULT_VERSION));
     }
 }
diff --git a/db-client-java/src/test/java/com/eventstore/dbclient/PluginsTests.java b/db-client-java/src/test/java/com/eventstore/dbclient/PluginsTests.java
new file mode 100644
index 0000000..40da4d0
--- /dev/null
+++ b/db-client-java/src/test/java/com/eventstore/dbclient/PluginsTests.java
@@ -0,0 +1,37 @@
+package com.eventstore.dbclient;
+
+import com.eventstore.dbclient.plugins.ClientCertificateAuthenticationTests;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * PluginsTests require the enterprise edition of EventStoreDB (>= 24.2.0).
+ */
+public class PluginsTests implements ClientCertificateAuthenticationTests {
+    static private Database database;
+    static private Logger logger;
+
+    @BeforeAll
+    public static void setup() {
+        database = DatabaseFactory.spawnEnterpriseWithPluginsEnabled("UserCertificates");
+        logger = LoggerFactory.getLogger(StreamsTests.class);
+    }
+
+    @Override
+    public Database getDatabase() {
+        return database;
+    }
+
+    @Override
+    public Logger getLogger() {
+        return logger;
+    }
+
+    @AfterAll
+    public static void cleanup() {
+        database.dispose();
+    }
+}
+
diff --git a/db-client-java/src/test/java/com/eventstore/dbclient/databases/DockerContainerDatabase.java b/db-client-java/src/test/java/com/eventstore/dbclient/databases/DockerContainerDatabase.java
index 733811a..aa523d3 100644
--- a/db-client-java/src/test/java/com/eventstore/dbclient/databases/DockerContainerDatabase.java
+++ b/db-client-java/src/test/java/com/eventstore/dbclient/databases/DockerContainerDatabase.java
@@ -8,28 +8,28 @@ import org.testcontainers.containers.wait.strategy.Wait;
 import java.io.File;
 import java.io.FileNotFoundException;
 import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
 
 public class DockerContainerDatabase extends GenericContainer<DockerContainerDatabase> implements Database {
-    private static final String REGISTRY;
-    private static final HealthCheck HEALTH_CHECK;
-
-    static {
-        REGISTRY = "ghcr.io/eventstore";
-        HEALTH_CHECK = new HealthCheck()
-                .withInterval(1000000000L)
-                .withTimeout(1000000000L)
-                .withRetries(10);
-    }
+    public static final String DEFAULT_REGISTRY = "ghcr.io/eventstore";
+    public static final String DEFAULT_IMAGE = "eventstore";
+    public static final String DEFAULT_VERSION = "latest";
 
     public static class Builder {
+        String registry;
         String image;
         String version;
         boolean secure;
         boolean anonymous;
+        Map<String, String> env;
 
         public Builder() {
-            this.image = "eventstore";
-            this.version = "latest";
+            this.registry = DEFAULT_REGISTRY;
+            this.image = DEFAULT_IMAGE;
+            this.version = DEFAULT_VERSION;
+            this.env = new HashMap<>();
         }
 
         public Builder secure(boolean secure) {
@@ -56,6 +56,16 @@ public class DockerContainerDatabase extends GenericContainer<DockerContainerDat
             return this;
         }
 
+        public Builder registry(String registry) {
+            this.registry = registry;
+            return this;
+        }
+
+        public Builder env(String envVar, String value) {
+            this.env.put(envVar, value);
+            return this;
+        }
+
         public DockerContainerDatabase build() {
             return new DockerContainerDatabase(this);
         }
@@ -65,7 +75,7 @@ public class DockerContainerDatabase extends GenericContainer<DockerContainerDat
     private final ClientTracker clientTracker;
 
     public DockerContainerDatabase(Builder builder) {
-        super(String.format("%s/%s:%s", REGISTRY, builder.image, builder.version));
+        super(String.format("%s/%s:%s", builder.registry, builder.image, builder.version));
         addExposedPorts(1113, 2113);
 
         withEnv("EVENTSTORE_RUN_PROJECTIONS", "ALL");
@@ -83,10 +93,16 @@ public class DockerContainerDatabase extends GenericContainer<DockerContainerDat
             withEnv("EVENTSTORE_INSECURE", "true");
         }
 
+        builder.env.forEach((envVar, value) -> withEnv(envVar, value));
+
         this.builder = builder;
         this.clientTracker = new ClientTracker();
 
-        withCreateContainerCmdModifier(cmd -> cmd.withHealthcheck(HEALTH_CHECK));
+        withCreateContainerCmdModifier(cmd -> cmd.withHealthcheck(new HealthCheck()
+                .withInterval(1000000000L)
+                .withTimeout(1000000000L)
+                .withRetries(10)));
+
         waitingFor(Wait.forHealthcheck());
 
         start();
@@ -134,11 +150,11 @@ public class DockerContainerDatabase extends GenericContainer<DockerContainerDat
 
     private static void verifyCertificatesExist() {
         String currentDir = System.getProperty("user.dir");
-        String[][] files =  {
-                 { "ca", "ca.crt" },
-                { "ca", "ca.key" },
-                { "node", "node.crt" },
-                { "node", "node.key" },
+        String[][] files = {
+                {"ca", "ca.crt"},
+                {"ca", "ca.key"},
+                {"node", "node.crt"},
+                {"node", "node.key"},
         };
 
         for (String[] strings : files) {
diff --git a/db-client-java/src/test/java/com/eventstore/dbclient/misc/ParseInvalidConnectionStringTests.java b/db-client-java/src/test/java/com/eventstore/dbclient/misc/ParseInvalidConnectionStringTests.java
index d927fa0..f0fcb56 100644
--- a/db-client-java/src/test/java/com/eventstore/dbclient/misc/ParseInvalidConnectionStringTests.java
+++ b/db-client-java/src/test/java/com/eventstore/dbclient/misc/ParseInvalidConnectionStringTests.java
@@ -25,7 +25,9 @@ public class ParseInvalidConnectionStringTests {
                 Arguments.of("esdb://localhost?keepAliveInterval=sdfksjsfl"),
                 Arguments.of("esdb://localhost?keepAliveTimeout=sdfksjsfl"),
                 Arguments.of("esdb://localhost?keepAliveTimeout=-3"),
-                Arguments.of("esdb://localhost?nodePreference=read_only_replica")
+                Arguments.of("esdb://localhost?nodePreference=read_only_replica"),
+                Arguments.of("esdb://localhost?userCertFile=/path/to/cert"),
+                Arguments.of("esdb://localhost?userKeyFile=/path/to/key")
        );
     }
 
diff --git a/db-client-java/src/test/java/com/eventstore/dbclient/misc/ParseValidConnectionStringTests.java b/db-client-java/src/test/java/com/eventstore/dbclient/misc/ParseValidConnectionStringTests.java
index 2bdba08..1adffb5 100644
--- a/db-client-java/src/test/java/com/eventstore/dbclient/misc/ParseValidConnectionStringTests.java
+++ b/db-client-java/src/test/java/com/eventstore/dbclient/misc/ParseValidConnectionStringTests.java
@@ -106,6 +106,10 @@ public class ParseValidConnectionStringTests {
                 Arguments.of(
                         "esdb://127.0.0.1:21573?tlsCaFile=/path/to/cert",
                         "{\"dnsDiscover\":false,\"maxDiscoverAttempts\":3,\"discoveryInterval\":500,\"gossipTimeout\":3000,\"nodePreference\":\"leader\",\"tls\":true,\"tlsVerifyCert\":true,\"throwOnAppendFailure\":true,\"hosts\":[{\"address\":\"127.0.0.1\",\"port\":21573}], \"tlsCaFile\": \"/path/to/cert\"}"
+                ),
+                Arguments.of(
+                        "esdb://127.0.0.1:21573?userCertFile=/path/to/cert&userKeyFile=/path/to/key",
+                        "{\"dnsDiscover\":false,\"maxDiscoverAttempts\":3,\"discoveryInterval\":500,\"gossipTimeout\":3000,\"nodePreference\":\"leader\",\"tls\":true,\"tlsVerifyCert\":true,\"throwOnAppendFailure\":true,\"hosts\":[{\"address\":\"127.0.0.1\",\"port\":21573}], \"defaultClientCertificate\": {\"clientCertFile\": \"/path/to/cert\", \"clientKeyFile\": \"/path/to/key\"}}"
                 )
         );
     }
@@ -122,6 +126,7 @@ public class ParseValidConnectionStringTests {
         Assertions.assertEquals(settings.getKeepAliveInterval(), other.getKeepAliveInterval());
         Assertions.assertEquals(settings.getDefaultDeadline(), other.getDefaultDeadline());
         Assertions.assertEquals(settings.getTlsCaFile(), other.getTlsCaFile());
+        Assertions.assertEquals(settings.getDefaultClientCertificate(), other.getDefaultClientCertificate());
 
         Assertions.assertEquals(settings.getHosts().length, other.getHosts().length);
         IntStream.range(0, settings.getHosts().length).forEach((i) -> {
@@ -198,11 +203,18 @@ public class ParseValidConnectionStringTests {
         if (tree.get("tlsCaFile") != null)
             builder.tlsCaFile(tree.get("tlsCaFile").asText());
 
+        if (tree.get("defaultClientCertificate") != null) {
+            JsonNode defaultClientCertificate = tree.get("defaultClientCertificate");
+
+            builder.defaultClientCertificate(
+                    defaultClientCertificate.get("clientCertFile").asText(),
+                    defaultClientCertificate.get("clientKeyFile").asText());
+        }
+
         tree.get("hosts").elements().forEachRemaining((host) -> {
             builder.addHost(new InetSocketAddress(host.get("address").asText(), host.get("port").asInt()));
         });
 
-
         return builder.buildConnectionSettings();
     }
 }
diff --git a/db-client-java/src/test/java/com/eventstore/dbclient/plugins/ClientCertificateAuthenticationTests.java b/db-client-java/src/test/java/com/eventstore/dbclient/plugins/ClientCertificateAuthenticationTests.java
new file mode 100644
index 0000000..19cf686
--- /dev/null
+++ b/db-client-java/src/test/java/com/eventstore/dbclient/plugins/ClientCertificateAuthenticationTests.java
@@ -0,0 +1,36 @@
+package com.eventstore.dbclient.plugins;
+
+import com.eventstore.dbclient.*;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.nio.file.Paths;
+
+public interface ClientCertificateAuthenticationTests extends ConnectionAware {
+    @Test
+    default void testClientCertificateAuthenticationWithValidCertificate() {
+        Assertions.assertDoesNotThrow(() -> {
+            EventStoreDBClient client = getDatabase()
+                    .createClient(getDatabase()
+                            .defaultSettingsBuilder()
+                            .defaultClientCertificate(ClientCertificate("admin"), userKey("admin"))
+                            .defaultCredentials(null)
+                            .buildConnectionSettings());
+
+            client.readAll().get();
+        });
+    }
+
+    static String ClientCertificate(String user) {
+        return buildCertPath(user, "crt");
+    }
+
+    static String userKey(String user) {
+        return buildCertPath(user, "key");
+    }
+
+    static String buildCertPath(String user, String extension) {
+        String certsPath = Paths.get(System.getProperty("user.dir"), "..", "certs").toAbsolutePath().toString();
+        return String.format("%s/user-%s/user-%s.%s", certsPath, user, user, extension);
+    }
+}