Skip to content

Commit 900c216

Browse files
committed
Add Credential.fromKeyStore() for client certificates
Deprecate `Credential.ofDynamic()` because it is not compatible with client certificates. Add `Cluster.credential()` as the new way to update the credential used by the cluster.
1 parent c84ecc4 commit 900c216

File tree

3 files changed

+207
-35
lines changed

3 files changed

+207
-35
lines changed

couchbase-analytics-java-client/src/main/java/com/couchbase/analytics/client/java/Cluster.java

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,12 @@ public class Cluster implements Queryable, Closeable {
3939
.pathComponentTransformer(Cluster::lowerSnakeCaseToLowerCamelCase)
4040
.build();
4141

42-
final QueryExecutor queryExecutor;
42+
volatile QueryExecutor queryExecutor;
43+
private volatile Credential credential;
4344

4445
private final String connectionString;
4546
private final ClusterOptions.Unmodifiable options;
47+
private final HttpUrl url;
4648

4749
private static HttpUrl parseAnalyticsUrl(String s) {
4850
HttpUrl url = HttpUrl.get(s);
@@ -71,19 +73,30 @@ private static HttpUrl parseAnalyticsUrl(String s) {
7173
return builder.build();
7274
}
7375

74-
private Cluster(String connectionString, Credential credential, ClusterOptions.Unmodifiable options) {
76+
private Cluster(String connectionString, Credential initalCredential, ClusterOptions.Unmodifiable options) {
7577
this.connectionString = requireNonNull(connectionString);
7678
this.options = requireNonNull(options);
79+
this.credential = requireNonNull(initalCredential);
80+
this.url = parseAnalyticsUrl(connectionString);
81+
this.queryExecutor = newQueryExecutor(this.options, this.url, this.credential);
82+
warnIfConfigurationIsInsecure(url, options);
83+
}
7784

78-
HttpUrl url = parseAnalyticsUrl(connectionString);
79-
this.queryExecutor = new QueryExecutor(
80-
new AnalyticsOkHttpClient(options, url, credential),
85+
private static QueryExecutor newQueryExecutor(
86+
ClusterOptions.Unmodifiable options,
87+
HttpUrl url,
88+
Credential credential
89+
) {
90+
return new QueryExecutor(
91+
new AnalyticsOkHttpClient(
92+
options,
93+
url,
94+
credential
95+
),
8196
url,
8297
credential,
8398
options
8499
);
85-
86-
warnIfConfigurationIsInsecure(url, options);
87100
}
88101

89102
private static void warnIfConfigurationIsInsecure(
@@ -204,6 +217,27 @@ private static String lowerSnakeCaseToLowerCamelCase(String s) {
204217
return sb.toString();
205218
}
206219

220+
/**
221+
* Sets the credential to use for future requests.
222+
*
223+
* @throws IllegalStateException if the given credential is of a different type than the current credential
224+
* (username and password / client certificate).
225+
*/
226+
public void credential(Credential newCredential) {
227+
requireNonNull(newCredential);
228+
229+
Class<?> oldClass = this.credential.getClass();
230+
Class<?> newClass = newCredential.getClass();
231+
if (!newClass.equals(oldClass)) {
232+
throw new IllegalStateException("Switching credential types at runtime is not supported; cannot switch from " + oldClass + " to " + newClass);
233+
}
234+
235+
this.credential = requireNonNull(newCredential);
236+
this.queryExecutor = newQueryExecutor(this.options, this.url, this.credential);
237+
238+
// Don't close the old query executor, because we don't want to interfere with in-flight requests.
239+
// The OkHttp resources automatically release themselves after a period of inactivity.
240+
}
207241

208242
/**
209243
* Returns the database in this cluster with the given name.

couchbase-analytics-java-client/src/main/java/com/couchbase/analytics/client/java/Credential.java

Lines changed: 161 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,24 @@
1919
import com.couchbase.analytics.client.java.internal.ThreadSafe;
2020
import okhttp3.Credentials;
2121
import okhttp3.tls.HandshakeCertificates;
22+
import okhttp3.tls.HeldCertificate;
23+
import org.jspecify.annotations.Nullable;
2224

25+
import java.io.InputStream;
26+
import java.nio.file.Files;
27+
import java.nio.file.Path;
28+
import java.security.GeneralSecurityException;
29+
import java.security.KeyPair;
30+
import java.security.KeyStore;
31+
import java.security.PrivateKey;
32+
import java.security.cert.Certificate;
33+
import java.security.cert.X509Certificate;
34+
import java.util.ArrayList;
35+
import java.util.Enumeration;
36+
import java.util.List;
2337
import java.util.function.Supplier;
2438

39+
import static com.couchbase.analytics.client.java.internal.utils.lang.CbCollections.listCopyOf;
2540
import static java.nio.charset.StandardCharsets.UTF_8;
2641
import static java.util.Objects.requireNonNull;
2742

@@ -30,61 +45,180 @@
3045
* <pre>
3146
* Credential.of(username, password)
3247
* </pre>
33-
* <p>
34-
* For advanced use cases involving dynamic credentials, see
35-
* {@link Credential#ofDynamic(Supplier)}.
48+
* Alternatively, to use a client certificate:
49+
* <pre>
50+
* Credential.fromKeyStore(
51+
* Paths.get("/path/to/client-cert.p12"),
52+
* "password"
53+
* )
54+
* </pre>
55+
*
56+
* @see Cluster#credential(Credential)
3657
*/
3758
@ThreadSafe
3859
public abstract class Credential {
3960

61+
private static class UsernameAndPassword extends Credential {
62+
private final String authHeaderValue;
63+
64+
UsernameAndPassword(String username, String password) {
65+
this.authHeaderValue = Credentials.basic(username, password, UTF_8);
66+
}
67+
68+
@Override
69+
String httpAuthorizationHeaderValue() {
70+
return authHeaderValue;
71+
}
72+
73+
@Override
74+
void addHeldCertificate(HandshakeCertificates.Builder builder) {
75+
}
76+
}
77+
78+
private static class ClientCertificate extends Credential {
79+
private final HeldCertificate heldCertificate;
80+
private final List<X509Certificate> intermediates;
81+
82+
public ClientCertificate(HeldCertificate heldCertificate, List<X509Certificate> intermediates) {
83+
this.heldCertificate = requireNonNull(heldCertificate);
84+
this.intermediates = listCopyOf(intermediates);
85+
}
86+
87+
@Override
88+
@Nullable String httpAuthorizationHeaderValue() {
89+
return null;
90+
}
91+
92+
@Override
93+
void addHeldCertificate(HandshakeCertificates.Builder builder) {
94+
builder.heldCertificate(heldCertificate, intermediates.toArray(new X509Certificate[0]));
95+
}
96+
}
97+
98+
private static class Dynamic extends Credential {
99+
private final Supplier<Credential> supplier;
100+
101+
public Dynamic(Supplier<Credential> supplier) {
102+
this.supplier = requireNonNull(supplier);
103+
}
104+
105+
@Override
106+
@Nullable String httpAuthorizationHeaderValue() {
107+
return supplier.get().httpAuthorizationHeaderValue();
108+
}
109+
110+
@Override
111+
void addHeldCertificate(HandshakeCertificates.Builder builder) {
112+
supplier.get().addHeldCertificate(builder);
113+
}
114+
}
115+
40116
/**
41117
* Returns a new instance that holds the given username and password.
42118
*/
43119
public static Credential of(String username, String password) {
44-
String authHeaderValue = Credentials.basic(username, password, UTF_8);
120+
return new UsernameAndPassword(username, password);
121+
}
122+
123+
/**
124+
* Returns a new instance that holds a client certificate loaded from the specified PKCS#12 key store file.
125+
* <p>
126+
* The key store must have a single entry which must contain a private key and certificate chain.
127+
* The same password must be used for integrity and encryption.
128+
* <p>
129+
* <b>TIP:</b>
130+
* One way to create a suitable PKCS#12 file from a PEM-encoded private key and certificate
131+
* is to concatenate the key and certificate into a file named "client-cert.pem", then run this command:
132+
* <pre>
133+
* openssl pkcs12 -export -in client-cert.pem -out client-cert.p12 -passout pass:password
134+
* </pre>
135+
* This creates a PKCS#12 file named "client-cert.p12" protected by the password "password".
136+
*
137+
* @param password for verifying key store integrity and decrypting the private key
138+
*/
139+
public static Credential fromKeyStore(Path pkcs12Path, @Nullable String password) {
140+
KeyStore keyStore = loadKeyStore(pkcs12Path, password);
141+
return fromKeyStore(keyStore, password);
142+
}
45143

46-
return new Credential() {
47-
@Override
48-
String httpAuthorizationHeaderValue() {
49-
return authHeaderValue;
144+
/**
145+
* Returns a new instance that holds a client certificate loaded from the specified key store.
146+
* <p>
147+
* The key store must have a single entry which must contain a private key and certificate chain.
148+
*
149+
* @param password for decrypting the private key
150+
*/
151+
public static Credential fromKeyStore(KeyStore keyStore, @Nullable String password) {
152+
try {
153+
List<String> aliases = toList(keyStore.aliases());
154+
if (aliases.size() != 1) {
155+
throw new IllegalArgumentException("Expected the key store to contain exactly one entry, but got aliases: " + aliases);
50156
}
157+
String alias = aliases.get(0);
158+
159+
PrivateKey privateKey = (PrivateKey) keyStore.getKey(alias, password == null ? null : password.toCharArray());
160+
Certificate[] chain = keyStore.getCertificateChain(alias);
161+
X509Certificate userCert = (X509Certificate) chain[0];
162+
163+
HeldCertificate heldCertificate = new HeldCertificate(
164+
new KeyPair(userCert.getPublicKey(), privateKey),
165+
userCert
166+
);
51167

52-
@Override
53-
void addHeldCertificate(HandshakeCertificates.Builder builder) {
54-
// noop
168+
List<X509Certificate> intermediates = new ArrayList<>();
169+
for (int i = 1; i < chain.length; i++) { // skip zero-th because that's the user's certificate
170+
intermediates.add((X509Certificate) chain[i]);
55171
}
56-
};
172+
173+
return new ClientCertificate(heldCertificate, intermediates);
174+
175+
} catch (ClassCastException | GeneralSecurityException e) {
176+
throw new RuntimeException("Failed to read client certificate from key store.", e);
177+
}
178+
}
179+
180+
private static KeyStore loadKeyStore(Path keyStorePath, @Nullable String password) {
181+
try (InputStream keyStoreInputStream = Files.newInputStream(keyStorePath)) {
182+
final KeyStore store = KeyStore.getInstance(KeyStore.getDefaultType());
183+
store.load(
184+
keyStoreInputStream,
185+
password != null ? password.toCharArray() : null
186+
);
187+
return store;
188+
} catch (Exception ex) {
189+
throw new RuntimeException("Failed to read key store.", ex);
190+
}
191+
}
192+
193+
private static <T> List<T> toList(Enumeration<T> e) {
194+
List<T> result = new ArrayList<>();
195+
while (e.hasMoreElements()) {
196+
result.add(e.nextElement());
197+
}
198+
return result;
57199
}
58200

59201
/**
60202
* Returns a new instance of a dynamic credential that invokes the given supplier
61203
* every time a credential is required.
62204
* <p>
63205
* This enables updating a credential without having to restart your application.
206+
*
207+
* @deprecated This method is not compatible with client certificate credentials.
208+
* Instead, please update the credential by calling {@link Cluster#credential(Credential)}.
64209
*/
210+
@Deprecated
65211
public static Credential ofDynamic(Supplier<Credential> supplier) {
66-
requireNonNull(supplier);
67-
68-
return new Credential() {
69-
@Override
70-
String httpAuthorizationHeaderValue() {
71-
return supplier.get().httpAuthorizationHeaderValue();
72-
}
73-
74-
@Override
75-
void addHeldCertificate(HandshakeCertificates.Builder builder) {
76-
supplier.get().addHeldCertificate(builder);
77-
}
78-
};
212+
return new Dynamic(supplier);
79213
}
80214

81-
abstract String httpAuthorizationHeaderValue();
215+
abstract @Nullable String httpAuthorizationHeaderValue();
82216

83217
abstract void addHeldCertificate(HandshakeCertificates.Builder builder);
84218

85219
/**
86220
* @see #of
87-
* @see #ofDynamic
221+
* @see #fromKeyStore
88222
*/
89223
private Credential() {
90224
}

couchbase-analytics-java-client/src/main/java/com/couchbase/analytics/client/java/QueryExecutor.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,9 +221,13 @@ QueryMetadata executeStreamingQueryOnce(
221221
Request.Builder requestBuilder = new Request.Builder()
222222
.url(url)
223223
.header("User-Agent", userAgent)
224-
.header("Authorization", credential.httpAuthorizationHeaderValue())
225224
.post(requestBody(query));
226225

226+
String authHeaderValue = credential.httpAuthorizationHeaderValue();
227+
if (authHeaderValue != null) {
228+
requestBuilder.header("Authorization", authHeaderValue);
229+
}
230+
227231
Request request = requestBuilder.build();
228232

229233
OkHttpClient client = httpClient.clientWithTimeout(timeout);

0 commit comments

Comments
 (0)