Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,12 @@ public class Cluster implements Queryable, Closeable {
.pathComponentTransformer(Cluster::lowerSnakeCaseToLowerCamelCase)
.build();

final QueryExecutor queryExecutor;
volatile QueryExecutor queryExecutor;
private volatile Credential credential;

private final String connectionString;
private final ClusterOptions.Unmodifiable options;
private final HttpUrl url;

private static HttpUrl parseAnalyticsUrl(String s) {
HttpUrl url = HttpUrl.get(s);
Expand Down Expand Up @@ -71,19 +73,30 @@ private static HttpUrl parseAnalyticsUrl(String s) {
return builder.build();
}

private Cluster(String connectionString, Credential credential, ClusterOptions.Unmodifiable options) {
private Cluster(String connectionString, Credential initalCredential, ClusterOptions.Unmodifiable options) {
this.connectionString = requireNonNull(connectionString);
this.options = requireNonNull(options);
this.credential = requireNonNull(initalCredential);
this.url = parseAnalyticsUrl(connectionString);
this.queryExecutor = newQueryExecutor(this.options, this.url, this.credential);
warnIfConfigurationIsInsecure(url, options);
}

HttpUrl url = parseAnalyticsUrl(connectionString);
this.queryExecutor = new QueryExecutor(
new AnalyticsOkHttpClient(options, url, credential),
private static QueryExecutor newQueryExecutor(
ClusterOptions.Unmodifiable options,
HttpUrl url,
Credential credential
) {
return new QueryExecutor(
new AnalyticsOkHttpClient(
options,
url,
credential
),
url,
credential,
options
);

warnIfConfigurationIsInsecure(url, options);
}

private static void warnIfConfigurationIsInsecure(
Expand Down Expand Up @@ -204,6 +217,27 @@ private static String lowerSnakeCaseToLowerCamelCase(String s) {
return sb.toString();
}

/**
* Sets the credential to use for future requests.
*
* @throws IllegalStateException if the given credential is of a different type than the current credential
* (username and password / client certificate).
*/
public void credential(Credential newCredential) {
requireNonNull(newCredential);

Class<?> oldClass = this.credential.getClass();
Class<?> newClass = newCredential.getClass();
if (!newClass.equals(oldClass)) {
throw new IllegalStateException("Switching credential types at runtime is not supported; cannot switch from " + oldClass + " to " + newClass);
}

this.credential = requireNonNull(newCredential);
this.queryExecutor = newQueryExecutor(this.options, this.url, this.credential);

// Don't close the old query executor, because we don't want to interfere with in-flight requests.
// The OkHttp resources automatically release themselves after a period of inactivity.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was going to ask about what happens with old requests. This is neat.

}

/**
* Returns the database in this cluster with the given name.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,24 @@
import com.couchbase.analytics.client.java.internal.ThreadSafe;
import okhttp3.Credentials;
import okhttp3.tls.HandshakeCertificates;
import okhttp3.tls.HeldCertificate;
import org.jspecify.annotations.Nullable;

import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
import java.util.function.Supplier;

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

Expand All @@ -30,61 +45,180 @@
* <pre>
* Credential.of(username, password)
* </pre>
* <p>
* For advanced use cases involving dynamic credentials, see
* {@link Credential#ofDynamic(Supplier)}.
* Alternatively, to use a client certificate:
* <pre>
* Credential.fromKeyStore(
* Paths.get("/path/to/client-cert.p12"),
* "password"
* )
* </pre>
*
* @see Cluster#credential(Credential)
*/
@ThreadSafe
public abstract class Credential {

private static class UsernameAndPassword extends Credential {
private final String authHeaderValue;

UsernameAndPassword(String username, String password) {
this.authHeaderValue = Credentials.basic(username, password, UTF_8);
}

@Override
String httpAuthorizationHeaderValue() {
return authHeaderValue;
}

@Override
void addHeldCertificate(HandshakeCertificates.Builder builder) {
}
}

private static class ClientCertificate extends Credential {
private final HeldCertificate heldCertificate;
private final List<X509Certificate> intermediates;

public ClientCertificate(HeldCertificate heldCertificate, List<X509Certificate> intermediates) {
this.heldCertificate = requireNonNull(heldCertificate);
this.intermediates = listCopyOf(intermediates);
}

@Override
@Nullable String httpAuthorizationHeaderValue() {
return null;
}

@Override
void addHeldCertificate(HandshakeCertificates.Builder builder) {
builder.heldCertificate(heldCertificate, intermediates.toArray(new X509Certificate[0]));
}
}

private static class Dynamic extends Credential {
private final Supplier<Credential> supplier;

public Dynamic(Supplier<Credential> supplier) {
this.supplier = requireNonNull(supplier);
}

@Override
@Nullable String httpAuthorizationHeaderValue() {
return supplier.get().httpAuthorizationHeaderValue();
}

@Override
void addHeldCertificate(HandshakeCertificates.Builder builder) {
supplier.get().addHeldCertificate(builder);
}
}

/**
* Returns a new instance that holds the given username and password.
*/
public static Credential of(String username, String password) {
String authHeaderValue = Credentials.basic(username, password, UTF_8);
return new UsernameAndPassword(username, password);
}

/**
* Returns a new instance that holds a client certificate loaded from the specified PKCS#12 key store file.
* <p>
* The key store must have a single entry which must contain a private key and certificate chain.
* The same password must be used for integrity and encryption.
* <p>
* <b>TIP:</b>
* One way to create a suitable PKCS#12 file from a PEM-encoded private key and certificate
* is to concatenate the key and certificate into a file named "client-cert.pem", then run this command:
* <pre>
* openssl pkcs12 -export -in client-cert.pem -out client-cert.p12 -passout pass:password
* </pre>
* This creates a PKCS#12 file named "client-cert.p12" protected by the password "password".
*
* @param password for verifying key store integrity and decrypting the private key
*/
public static Credential fromKeyStore(Path pkcs12Path, @Nullable String password) {
KeyStore keyStore = loadKeyStore(pkcs12Path, password);
return fromKeyStore(keyStore, password);
}

return new Credential() {
@Override
String httpAuthorizationHeaderValue() {
return authHeaderValue;
/**
* Returns a new instance that holds a client certificate loaded from the specified key store.
* <p>
* The key store must have a single entry which must contain a private key and certificate chain.
*
* @param password for decrypting the private key
*/
public static Credential fromKeyStore(KeyStore keyStore, @Nullable String password) {
try {
List<String> aliases = toList(keyStore.aliases());
if (aliases.size() != 1) {
throw new IllegalArgumentException("Expected the key store to contain exactly one entry, but got aliases: " + aliases);
}
String alias = aliases.get(0);

PrivateKey privateKey = (PrivateKey) keyStore.getKey(alias, password == null ? null : password.toCharArray());
Certificate[] chain = keyStore.getCertificateChain(alias);
X509Certificate userCert = (X509Certificate) chain[0];

HeldCertificate heldCertificate = new HeldCertificate(
new KeyPair(userCert.getPublicKey(), privateKey),
userCert
);

@Override
void addHeldCertificate(HandshakeCertificates.Builder builder) {
// noop
List<X509Certificate> intermediates = new ArrayList<>();
for (int i = 1; i < chain.length; i++) { // skip zero-th because that's the user's certificate
intermediates.add((X509Certificate) chain[i]);
}
};

return new ClientCertificate(heldCertificate, intermediates);

} catch (ClassCastException | GeneralSecurityException e) {
throw new RuntimeException("Failed to read client certificate from key store.", e);
}
}

private static KeyStore loadKeyStore(Path keyStorePath, @Nullable String password) {
try (InputStream keyStoreInputStream = Files.newInputStream(keyStorePath)) {
final KeyStore store = KeyStore.getInstance(KeyStore.getDefaultType());
store.load(
keyStoreInputStream,
password != null ? password.toCharArray() : null
);
return store;
} catch (Exception ex) {
throw new RuntimeException("Failed to read key store.", ex);
}
}

private static <T> List<T> toList(Enumeration<T> e) {
List<T> result = new ArrayList<>();
while (e.hasMoreElements()) {
result.add(e.nextElement());
}
return result;
}

/**
* Returns a new instance of a dynamic credential that invokes the given supplier
* every time a credential is required.
* <p>
* This enables updating a credential without having to restart your application.
*
* @deprecated This method is not compatible with client certificate credentials.
* Instead, please update the credential by calling {@link Cluster#credential(Credential)}.
*/
@Deprecated
public static Credential ofDynamic(Supplier<Credential> supplier) {
requireNonNull(supplier);

return new Credential() {
@Override
String httpAuthorizationHeaderValue() {
return supplier.get().httpAuthorizationHeaderValue();
}

@Override
void addHeldCertificate(HandshakeCertificates.Builder builder) {
supplier.get().addHeldCertificate(builder);
}
};
return new Dynamic(supplier);
}

abstract String httpAuthorizationHeaderValue();
abstract @Nullable String httpAuthorizationHeaderValue();

abstract void addHeldCertificate(HandshakeCertificates.Builder builder);

/**
* @see #of
* @see #ofDynamic
* @see #fromKeyStore
*/
private Credential() {
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,9 +221,13 @@ QueryMetadata executeStreamingQueryOnce(
Request.Builder requestBuilder = new Request.Builder()
.url(url)
.header("User-Agent", userAgent)
.header("Authorization", credential.httpAuthorizationHeaderValue())
.post(requestBody(query));

String authHeaderValue = credential.httpAuthorizationHeaderValue();
if (authHeaderValue != null) {
requestBuilder.header("Authorization", authHeaderValue);
}

Request request = requestBuilder.build();

OkHttpClient client = httpClient.clientWithTimeout(timeout);
Expand Down