Skip to content

Commit 65cfc56

Browse files
committed
SDKQE-3764 FIT: Add support for certificate authentication to Java FIT performer
Change-Id: Ica37d0df38bbb406390373960e1efd8e632d1425 Reviewed-on: https://review.couchbase.org/c/couchbase-jvm-clients/+/236103 Reviewed-by: Graham Pople <[email protected]> Tested-by: Build Bot <[email protected]>
1 parent ce716cd commit 65cfc56

File tree

5 files changed

+115
-20
lines changed

5 files changed

+115
-20
lines changed

core-fit-performer/pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@
2525
<scope>compile</scope>
2626
</dependency>
2727

28+
<!-- For parsing PEM-encoded client certificate private key -->
29+
<dependency>
30+
<groupId>org.bouncycastle</groupId>
31+
<artifactId>bcprov-debug-lts8on</artifactId>
32+
<version>2.73.9</version>
33+
</dependency>
34+
2835
<dependency>
2936
<groupId>com.google.code.gson</groupId>
3037
<artifactId>gson</artifactId>
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Copyright 2025 Couchbase, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.couchbase.client.performer.core.util;
18+
19+
import org.bouncycastle.jce.provider.BouncyCastleProvider;
20+
import org.bouncycastle.util.io.pem.PemObject;
21+
import org.bouncycastle.util.io.pem.PemReader;
22+
23+
import java.io.IOException;
24+
import java.io.StringReader;
25+
import java.security.GeneralSecurityException;
26+
import java.security.KeyFactory;
27+
import java.security.PrivateKey;
28+
import java.security.Provider;
29+
import java.security.interfaces.RSAPrivateCrtKey;
30+
import java.security.spec.PKCS8EncodedKeySpec;
31+
32+
public class PemUtil {
33+
private PemUtil() {
34+
}
35+
36+
private static final Provider BOUNCY_CASTLE = new BouncyCastleProvider();
37+
38+
public static RSAPrivateCrtKey parseRsaPrivateCrtKey(String pem) {
39+
try (PemReader pemReader = new PemReader(new StringReader(pem))) {
40+
PemObject pemObject = pemReader.readPemObject();
41+
byte[] keyBytes = pemObject.getContent();
42+
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
43+
44+
// The standard RSAKeyFactory refuses to parse our PEM-encoded RSAPrivateCrtKey,
45+
// so let Bouncy Castle do the heavy lifting.
46+
KeyFactory kf = KeyFactory.getInstance("RSA", BOUNCY_CASTLE);
47+
48+
PrivateKey result = kf.generatePrivate(keySpec);
49+
return (RSAPrivateCrtKey) result;
50+
51+
} catch (IOException | GeneralSecurityException e) {
52+
throw new RuntimeException("Failed to parse RSA private certificate key", e);
53+
}
54+
}
55+
}

java-fit-performer/src/main/java/com/couchbase/JavaPerformer.java

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
package com.couchbase;
1717

1818
import com.couchbase.client.core.cnc.RequestSpan;
19+
import com.couchbase.client.core.env.Authenticator;
20+
import com.couchbase.client.core.env.CertificateAuthenticator;
21+
import com.couchbase.client.core.env.PasswordAuthenticator;
22+
import com.couchbase.client.core.env.SecurityConfig;
1923
import com.couchbase.client.core.io.CollectionIdentifier;
2024
import com.couchbase.client.core.logging.LogRedaction;
2125
import com.couchbase.client.core.logging.RedactionLevel;
@@ -57,6 +61,7 @@
5761
import com.couchbase.utils.ResultsUtil;
5862
import com.couchbase.utils.HooksUtil;
5963
// [end]
64+
import com.couchbase.client.performer.core.util.PemUtil;
6065
import com.couchbase.client.performer.core.util.VersionUtil;
6166
import com.couchbase.client.protocol.observability.SpanCreateRequest;
6267
import com.couchbase.client.protocol.observability.SpanCreateResponse;
@@ -81,7 +86,6 @@
8186
import com.couchbase.utils.Capabilities;
8287
import com.couchbase.utils.ClusterConnection;
8388
import com.couchbase.utils.OptionsUtil;
84-
import com.couchbase.utils.UserSchedulerUtil;
8589
import io.grpc.Server;
8690
import io.grpc.ServerBuilder;
8791
import io.grpc.Status;
@@ -94,6 +98,7 @@
9498
import java.io.IOException;
9599
import java.util.ArrayList;
96100
import java.util.Arrays;
101+
import java.util.List;
97102
import java.util.Optional;
98103
import java.util.concurrent.ConcurrentHashMap;
99104
import java.util.concurrent.atomic.AtomicReference;
@@ -206,6 +211,33 @@ protected void customisePerformerCaps(PerformerCapsFetchResponse.Builder respons
206211
response.setPerformerUserAgent("java-sdk");
207212
}
208213

214+
private Authenticator getSdkAuthenticator(ClusterConnectionCreateRequest request) {
215+
if (!request.hasAuthenticator()) {
216+
return PasswordAuthenticator.create(
217+
request.getClusterUsername(),
218+
request.getClusterPassword()
219+
);
220+
}
221+
222+
var fitAuth = request.getAuthenticator();
223+
if (fitAuth.hasPasswordAuth()) {
224+
var fitUsernameAndPassword = fitAuth.getPasswordAuth();
225+
return PasswordAuthenticator.create(
226+
fitUsernameAndPassword.getUsername(),
227+
fitUsernameAndPassword.getPassword()
228+
);
229+
}
230+
231+
if (fitAuth.hasCertificateAuth()) {
232+
var fitClientCert = fitAuth.getCertificateAuth();
233+
var privateKey = PemUtil.parseRsaPrivateCrtKey(fitClientCert.getKey());
234+
var certChain = SecurityConfig.decodeCertificates(List.of(fitClientCert.getCert()));
235+
return CertificateAuthenticator.fromKey(privateKey, null, certChain);
236+
}
237+
238+
throw new UnsupportedOperationException("Unrecognized authenticator: " + fitAuth);
239+
}
240+
209241
@Override
210242
public void clusterConnectionCreate(ClusterConnectionCreateRequest request,
211243
StreamObserver<ClusterConnectionCreateResponse> responseObserver) {
@@ -231,15 +263,16 @@ public void clusterConnectionCreate(ClusterConnectionCreateRequest request,
231263
});
232264
});
233265

266+
Authenticator authenticator = getSdkAuthenticator(request);
267+
234268
// [if:3.2.6]
235269
// 3.2.6 added an easy way for SDK users to configure the SDK without having to take ownership of
236270
// ClusterEnvironment management. It also allows passing parameters in the connection string, which
237271
// is not allowed with those externally owned ClusterEnvironments.
238272
var clusterEnvironment = OptionsUtil.convertClusterConfigToConsumer(request, getCluster, onClusterConnectionClose);
239273

240274
var connection = new ClusterConnection(request.getClusterHostname(),
241-
request.getClusterUsername(),
242-
request.getClusterPassword(),
275+
authenticator,
243276
clusterEnvironment,
244277
onClusterConnectionClose);
245278
// [end]
@@ -249,8 +282,7 @@ public void clusterConnectionCreate(ClusterConnectionCreateRequest request,
249282
//? var clusterEnvironment = OptionsUtil.convertClusterConfig(request, getCluster, onClusterConnectionClose);
250283

251284
//? var connection = new ClusterConnection(request.getClusterHostname(),
252-
//? request.getClusterUsername(),
253-
//? request.getClusterPassword(),
285+
//? authenticator,
254286
//? clusterEnvironment,
255287
//? onClusterConnectionClose);
256288
// [end]
@@ -261,7 +293,7 @@ public void clusterConnectionCreate(ClusterConnectionCreateRequest request,
261293

262294
// Fine to have a default and a per-test connection open, any more suggests a leak
263295
logger.info("Dumping {} cluster connections for resource leak troubleshooting:", clusterConnections.size());
264-
clusterConnections.forEach((key, value) -> logger.info("Cluster connection {} {}", key, value.username));
296+
clusterConnections.forEach((key, value) -> logger.info("Cluster connection {} {}", key, value.authenticator));
265297

266298
responseObserver.onNext(ClusterConnectionCreateResponse.newBuilder()
267299
.setClusterConnectionCount(clusterConnections.size())
@@ -291,8 +323,8 @@ public void transactionCreate(TransactionCreateRequest request,
291323
try {
292324
ClusterConnection connection = getClusterConnection(request.getClusterConnectionId());
293325

294-
logger.info("Starting transaction on cluster connection {} created for user {}",
295-
request.getClusterConnectionId(), connection.username);
326+
logger.info("Starting transaction on cluster connection {} created with authenticator {}",
327+
request.getClusterConnectionId(), connection.authenticator);
296328

297329
TransactionResult response;
298330
var counters = new Counters();
@@ -478,9 +510,9 @@ public void transactionSingleQuery(TransactionSingleQueryRequest request,
478510
try {
479511
var connection = getClusterConnection(request.getClusterConnectionId());
480512

481-
logger.info("Performing single query transaction on cluster connection {} (user {})",
513+
logger.info("Performing single query transaction on cluster connection {} (authenticator {})",
482514
request.getClusterConnectionId(),
483-
connection.username);
515+
connection.authenticator);
484516

485517
TransactionSingleQueryResponse ret = SingleQueryTransactionExecutor.execute(request, connection, spans);
486518

java-fit-performer/src/main/java/com/couchbase/utils/Capabilities.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ public static List<Caps> sdkImplementationCaps() {
101101
out.add(Caps.SDK_PREFILTER_VECTOR_SEARCH);
102102
// [end]
103103

104+
out.add(Caps.SUPPORTS_AUTHENTICATOR);
105+
104106
return out;
105107
}
106108
}

java-fit-performer/src/main/java/com/couchbase/utils/ClusterConnection.java

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818

1919
import com.couchbase.client.core.Core;
20+
import com.couchbase.client.core.env.Authenticator;
2021
import com.couchbase.client.core.io.CollectionIdentifier;
2122
import com.couchbase.client.java.Cluster;
2223
import com.couchbase.client.java.ClusterOptions;
@@ -35,20 +36,19 @@
3536
public class ClusterConnection {
3637
private final Cluster cluster;
3738
@Nullable private final ClusterEnvironment environmentOwnedByCaller;
38-
public final String username;
39+
public final Authenticator authenticator;
3940
// Commands to run when this ClusterConnection is being closed. Allows closing other related resources that have
4041
// the same lifetime.
4142
private final List<Runnable> onClusterConnectionClose;
4243

4344
public ClusterConnection(String hostname,
44-
String username,
45-
String password,
45+
Authenticator authenticator,
4646
@Nullable ClusterEnvironment.Builder config,
4747
ArrayList<Runnable> onClusterConnectionClose) {
48-
this.username = username;
49-
this.onClusterConnectionClose = onClusterConnectionClose;
48+
this.authenticator = authenticator;
49+
this.onClusterConnectionClose = onClusterConnectionClose;
5050

51-
var co = ClusterOptions.clusterOptions(username, password);
51+
var co = ClusterOptions.clusterOptions(authenticator);
5252
if (config != null) {
5353
this.environmentOwnedByCaller = config.build();
5454
co.environment(this.environmentOwnedByCaller);
@@ -62,14 +62,13 @@ public ClusterConnection(String hostname,
6262

6363
// [if:3.2.6]
6464
public ClusterConnection(String hostname,
65-
String username,
66-
String password,
65+
Authenticator authenticator,
6766
@Nullable Consumer<ClusterEnvironment.Builder> config,
6867
ArrayList<Runnable> onClusterConnectionClose) {
69-
this.username = username;
68+
this.authenticator = authenticator;
7069
this.onClusterConnectionClose = onClusterConnectionClose;
7170

72-
var co = ClusterOptions.clusterOptions(username, password)
71+
var co = ClusterOptions.clusterOptions(authenticator)
7372
.environment(env -> {
7473
if (config != null) {
7574
config.accept(env);

0 commit comments

Comments
 (0)