Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
6ed0d52
Added core API for changing credentials
chernser Mar 30, 2026
ffbdd84
fix encoding
chernser Mar 30, 2026
bb1cfa8
Merge branch 'main' into 03/30/26/implement_change_credentials
chernser Apr 7, 2026
32706eb
fixed test
chernser Apr 7, 2026
73aa2a5
Implemented credentials manager as self entity controlling credentials
chernser Apr 8, 2026
bd75791
added usage example
chernser Apr 8, 2026
781bddf
Merge branch 'main' into 03/30/26/implement_change_credentials
chernser Apr 29, 2026
2233943
Made CredentialsManager handle values via baked map
chernser Apr 29, 2026
9bbedfa
fix minor issues
chernser Apr 29, 2026
e50d874
added documentation with migration part
chernser Apr 29, 2026
56465c3
Removed not needed changes.
chernser May 1, 2026
208caf8
fixed null password issue
chernser May 1, 2026
b0e3895
fixed checking configuration for custom header
chernser May 1, 2026
1e4f5ec
updated docs and code to match
chernser May 1, 2026
67f51fd
updated change log and methods documentation
chernser May 1, 2026
f479763
moved new values validation to CredentialsManager
chernser May 1, 2026
abefd55
Merge branch 'main' into 03/30/26/implement_change_credentials
chernser May 15, 2026
0a6a277
code cleanup
chernser May 15, 2026
c4660aa
Merge branch 'main' into 03/30/26/implement_change_credentials
chernser May 18, 2026
7bb18e4
fixed test that set invalid configuration for access token
chernser May 18, 2026
441e836
Disable HTTP basic auth
chernser May 19, 2026
be6b4c2
Disable HTTP basic auth
chernser May 19, 2026
1322fb6
Disable HTTP basic auth
chernser May 19, 2026
cad55b5
fix test by using correct JWT host
chernser May 19, 2026
6037235
fix test by using correct JWT host
chernser May 19, 2026
af426af
fix - JWT try to use default db
chernser May 19, 2026
4750094
Added concurrent tests
chernser May 27, 2026
4520f45
Merge branch 'main' into 03/30/26/implement_change_credentials
chernser May 27, 2026
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,20 @@

(https://github.com/ClickHouse/clickhouse-java/issues/2652, https://github.com/ClickHouse/clickhouse-java/issues/2825)

### Breaking Changes

- **[client-v2]** `Client.Builder#build()` now throws `ClientMisconfigurationException` instead of `IllegalArgumentException` for authentication and SSL misconfiguration (missing credentials, conflicting authentication methods, missing client certificate when SSL authentication is enabled, and trust store used together with a client certificate). Callers that relied on catching `IllegalArgumentException` from `build()` for these cases must catch `ClientMisconfigurationException` (which extends `RuntimeException` via `ClientException`).

- **[client-v2]** Combining `setUsername(...)` + `setPassword(...)` with a custom `Authorization` HTTP header (`httpHeader(HttpHeaders.AUTHORIZATION, ...)`) now fails at `Client.Builder#build()` with `ClientMisconfigurationException` unless HTTP Basic authentication is explicitly disabled via `useHTTPBasicAuth(false)`. Previously this combination was accepted and the custom `Authorization` header overrode the ClickHouse user/password headers at request time.

- **[client-v2]** The `access_token` configuration property (set via `Client.Builder#setAccessToken(String)` or directly through `setOption`) is now actually applied to outgoing requests as the `Authorization` HTTP header value verbatim. Previously the value was stored under `access_token` but never sent on the wire, so providing it alone had no effect on authentication. Callers must include the scheme prefix themselves (e.g. `setAccessToken("Bearer <token>")`), or use `useBearerTokenAuth(String)` which prepends `Bearer ` automatically.

- **[client-v2]** `Client.Builder#useBearerTokenAuth(String)` now stores the bearer token under the `access_token` configuration key (with the `Bearer ` prefix) instead of writing it directly into `http_header_authorization`. The HTTP wire format is unchanged, but the token is no longer observable through `Client#getReadOnlyConfig()` under the `http_header_authorization` key.

### New Features

- **[client-v2]** Added runtime credential update APIs on `Client`: `updateUserAndPassword(String, String)`, `updateAccessToken(String)`, and `updateBearerToken(String)`. Subsequent requests on the same `Client` instance use the new credentials without rebuilding the client. The authentication method is fixed at construction time; calling a runtime updater that does not match the configured method throws `ClientMisconfigurationException`. See `docs/authentication.md` for details and migration guidance.

- **[jdbc-v2]** Added `cluster_name` configuration property to specify a target cluster for statements like `KILL QUERY` that require an `ON CLUSTER` clause to execute across all nodes. (https://github.com/ClickHouse/clickhouse-java/issues/2837)

- **[client-v2, jdbc-v2]** Added support for ClickHouse `Geometry` type for ClickHouse `25.11+`, where `Geometry` changed from a `String` alias to `Variant(Point, Ring, LineString, MultiLineString, Polygon, MultiPolygon)` (client still compatible with older versions). Includes client read/write handling and JDBC type mapping for retrieving and inserting geometry values. Current writes infer the target geometry variant from array nesting depth, so `Ring` vs `LineString` and `Polygon` vs `MultiLineString` are not yet distinguishable through the generic `Geometry` write path. (https://github.com/ClickHouse/clickhouse-java/pull/2815)
Expand Down
84 changes: 55 additions & 29 deletions client-v2/src/main/java/com/clickhouse/client/api/Client.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import com.clickhouse.client.api.insert.InsertResponse;
import com.clickhouse.client.api.insert.InsertSettings;
import com.clickhouse.client.api.internal.ClientStatisticsHolder;
import com.clickhouse.client.api.internal.CredentialsManager;
import com.clickhouse.client.api.internal.HttpAPIClientHelper;
import com.clickhouse.client.api.internal.MapUtils;
import com.clickhouse.client.api.internal.TableSchemaParser;
Expand Down Expand Up @@ -44,7 +45,6 @@
import org.apache.hc.core5.concurrent.DefaultThreadFactory;
import org.apache.hc.core5.http.ClassicHttpResponse;
import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HttpHeaders;
import org.apache.hc.core5.http.HttpStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand Down Expand Up @@ -105,7 +105,8 @@
*
*
*
* <p>Client is thread-safe. It uses exclusive set of object to perform an operation.</p>
* <p>Client is thread-safe. It uses exclusive set of object to perform an operation.
* Exception is client global authentication configuration. Application should handle it in the way it is designed.</p>
*
*/
public class Client implements AutoCloseable {
Expand Down Expand Up @@ -140,11 +141,13 @@ public class Client implements AutoCloseable {
private final int retries;
private LZ4Factory lz4Factory = null;
private final Supplier<String> queryIdGenerator;
private final CredentialsManager credentialsManager;

private Client(Collection<Endpoint> endpoints, Map<String,String> configuration,
ExecutorService sharedOperationExecutor, ColumnToMethodMatchingStrategy columnToMethodMatchingStrategy,
Object metricsRegistry, Supplier<String> queryIdGenerator) {
Map<String, Object> parsedConfiguration = ClientConfigProperties.parseConfigMap(configuration);
Object metricsRegistry, Supplier<String> queryIdGenerator, CredentialsManager cManager) {
Map<String, Object> parsedConfiguration = new ConcurrentHashMap<>(ClientConfigProperties.parseConfigMap(configuration));
this.credentialsManager = cManager;
this.session = Session.extractFrom(parsedConfiguration);
this.configuration = new ConcurrentHashMap<>(parsedConfiguration);
this.readOnlyConfig = Collections.unmodifiableMap(configuration);
Expand Down Expand Up @@ -1039,12 +1042,13 @@ public Builder setOptions(Map<String, String> options) {
* Specifies whether to use Bearer Authentication and what token to use.
* The token will be sent as is, so it should be encoded before passing to this method.
*
* @param bearerToken - token to use
* @param bearerToken - token to use (without {@code Bearer} prefix)
* @return same instance of the builder
*/
public Builder useBearerTokenAuth(String bearerToken) {
// Most JWT libraries (https://jwt.io/libraries?language=Java) compact tokens in proper way
this.httpHeader(HttpHeaders.AUTHORIZATION, "Bearer " + bearerToken);
// Bearer token in subset of access token
setAccessToken(CredentialsManager.AUTH_HEADER_BEARER_PREFIX + bearerToken);
return this;
}

Expand Down Expand Up @@ -1128,28 +1132,12 @@ public Client build() {
if (this.endpoints.isEmpty()) {
throw new IllegalArgumentException("At least one endpoint is required");
}
// check if username and password are empty. so can not initiate client?
boolean useSslAuth = MapUtils.getFlag(this.configuration, ClientConfigProperties.SSL_AUTH.getKey());
boolean hasAccessToken = this.configuration.containsKey(ClientConfigProperties.ACCESS_TOKEN.getKey());
boolean hasUser = this.configuration.containsKey(ClientConfigProperties.USER.getKey());
boolean hasPassword = this.configuration.containsKey(ClientConfigProperties.PASSWORD.getKey());
boolean customHttpHeaders = this.configuration.containsKey(ClientConfigProperties.httpHeader(HttpHeaders.AUTHORIZATION));

if (!(useSslAuth || hasAccessToken || hasUser || hasPassword || customHttpHeaders)) {
throw new IllegalArgumentException("Username and password (or access token or SSL authentication or pre-define Authorization header) are required");
}

if (useSslAuth && (hasAccessToken || hasPassword)) {
throw new IllegalArgumentException("Only one of password, access token or SSL authentication can be used per client.");
}

if (useSslAuth && !this.configuration.containsKey(ClientConfigProperties.SSL_CERTIFICATE.getKey())) {
throw new IllegalArgumentException("SSL authentication requires a client certificate");
}
CredentialsManager cManager = new CredentialsManager(this.configuration);

if (this.configuration.containsKey(ClientConfigProperties.SSL_TRUST_STORE.getKey()) &&
this.configuration.containsKey(ClientConfigProperties.SSL_CERTIFICATE.getKey())) {
throw new IllegalArgumentException("Trust store and certificates cannot be used together");
if (configuration.containsKey(ClientConfigProperties.SSL_TRUST_STORE.getKey()) &&
configuration.containsKey(ClientConfigProperties.SSL_CERTIFICATE.getKey())) {
throw new ClientMisconfigurationException("Trust store and certificates cannot be used together");
}

// Check timezone settings
Expand Down Expand Up @@ -1181,7 +1169,7 @@ public Client build() {
}

return new Client(this.endpoints, this.configuration, this.sharedOperationExecutor,
this.columnToMethodMatchingStrategy, this.metricRegistry, this.queryIdGenerator);
this.columnToMethodMatchingStrategy, this.metricRegistry, this.queryIdGenerator, cManager);
}
}

Expand Down Expand Up @@ -2113,7 +2101,8 @@ public String toString() {
}

/**
* Returns unmodifiable map of configuration options.
* Returns unmodifiable map of initial configuration options.
* As authentication configuration values can change this map doesn't reflect them.
* @return - configuration options
*/
public Map<String, String> getConfiguration() {
Expand Down Expand Up @@ -2193,8 +2182,44 @@ public Collection<String> getDBRoles() {
return unmodifiableDbRolesView;
}


/**
* Updates Bearer token for other requests.
* This method is not thread-safe with respect to other credential updates
* or concurrent request execution. Applications must coordinate access if
* they require stronger consistency.
* Method doesn't allow to switch authentication type
* @param bearer - token to use without {@code "Bearer"} prefix.
*/
public void updateBearerToken(String bearer) {
this.configuration.put(ClientConfigProperties.httpHeader(HttpHeaders.AUTHORIZATION), "Bearer " + bearer);
ValidationUtils.checkNonBlank(bearer, "Bearer token");
updateAccessToken(CredentialsManager.AUTH_HEADER_BEARER_PREFIX + bearer);
}

/**
* Updates the user and password for all subsequential requests.
* This method is not thread-safe with respect to other credential updates
* or concurrent request execution. Applications must coordinate access if
* they require stronger consistency.
* Method doesn't allow to switch authentication type
* @param username user name
* @param password user password
* @throws ClientMisconfigurationException if another authentication type in use.
*/
public void updateUserAndPassword(String username, String password) {
this.credentialsManager.setCredentials(username, password);
}

/**
* Updates access token for the client. Change will be applied to all following requests.
* This method is not thread-safe with respect to other credential updates
* or concurrent request execution. Applications must coordinate access if
* they require stronger consistency.
* Method doesn't allow to switch authentication type
* @param accessToken - plain text access token
*/
public void updateAccessToken(String accessToken) {
this.credentialsManager.setAccessToken(accessToken);
}

private Endpoint getNextAliveNode() {
Expand All @@ -2212,6 +2237,7 @@ private Endpoint getNextAliveNode() {
private Map<String, Object> buildRequestSettings(Map<String, Object> opSettings) {
Map<String, Object> requestSettings = new HashMap<>(configuration);
session.applyTo(requestSettings);
credentialsManager.applyCredentials(requestSettings);
requestSettings.putAll(opSettings);
return requestSettings;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.clickhouse.client.api.internal;

/**
* Class containing utility methods used across the client.
*/
public final class ClientUtils {

private ClientUtils() {}

public static boolean isNotBlank(String str) {
return str != null && !str.trim().isEmpty();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package com.clickhouse.client.api.internal;

import com.clickhouse.client.api.ClientConfigProperties;
import com.clickhouse.client.api.ClientMisconfigurationException;
import org.apache.hc.core5.http.HttpHeaders;

import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;

/**
* Manages mutable authentication-related client settings.
* This class uses an atomic reference to ensure thread-safe credential updates.
* Updates are non-blocking and will be applied to newly initiated requests
* immediately without affecting ongoing queries.
*/
public class CredentialsManager {

public static final String AUTHORIZATION_HEADER_KEY =
ClientConfigProperties.httpHeader(HttpHeaders.AUTHORIZATION);
public static final String AUTH_HEADER_BEARER_PREFIX = "Bearer ";

private final AtomicReference<Map<String, Object>> authConfig = new AtomicReference<>();

private final boolean hasUserPassword;

private final boolean hasAccessToken;

public CredentialsManager(Map<String, String> config) {
this.hasUserPassword = isUserPassword(config);
Comment thread
chernser marked this conversation as resolved.
this.hasAccessToken = isAccessToken(config);

boolean sslAuthEnabled = isSslAuth(config);
boolean hasAuthHeader = isCustomAuthHeader(config);

final long authMethodsCount = Arrays
Comment thread
chernser marked this conversation as resolved.
.stream(new Boolean[]{hasUserPassword, hasAccessToken, sslAuthEnabled, hasAuthHeader})
.filter(b -> b).count();

String username = config.get(ClientConfigProperties.USER.getKey());
if (authMethodsCount == 1 && !hasAuthHeader) {
// Auth header handled specially
String password = config.getOrDefault(ClientConfigProperties.PASSWORD.getKey(), "");
boolean useSslAuth = MapUtils.getFlag(config, ClientConfigProperties.SSL_AUTH.getKey(), false);
String accessToken = config.get(ClientConfigProperties.ACCESS_TOKEN.getKey());
updateBackedConfig(username, password, useSslAuth, accessToken);
} else if (authMethodsCount == 0 && ClientUtils.isNotBlank(username)) {
// password not set - it is still user, password case if no other auth
String password = config.getOrDefault(ClientConfigProperties.PASSWORD.getKey(), "");
updateBackedConfig(username, password, false, null);
} else if (authMethodsCount == 0) {
throw new ClientMisconfigurationException(NO_AUTH_ERR_MSG);
} else if (hasAuthHeader) {
if (hasUserPassword && MapUtils.getFlag(config, ClientConfigProperties.HTTP_USE_BASIC_AUTH.getKey(),
ClientConfigProperties.HTTP_USE_BASIC_AUTH.getDefObjVal())) {
throw new ClientMisconfigurationException(PASSWORD_AND_AUTH_HEADER_BASIC_AUTH_ERR_MSG);
}
String password = config.getOrDefault(ClientConfigProperties.PASSWORD.getKey(), "");
String authHeader = config.getOrDefault(AUTHORIZATION_HEADER_KEY, "");
updateBackedConfig(username, password, false, authHeader);
} else {
throw new ClientMisconfigurationException(ONLY_ONE_METHOD_ERR_MSG);
}
}

public void applyCredentials(Map<String, Object> target) {
Map<String, Object> properties = authConfig.get();
target.putAll(properties);
}

private static final String AUTH_CANNOT_BE_SWITCHED_ERR_MSG = "Authentication type cannot be switched at runtime";

/**
* Replaces the current username/password credentials.
*
* <p>Updates are applied atomically and take effect for newly initiated requests
* without blocking or requiring external synchronization.
*/
public void setCredentials(String username, String password) {
ValidationUtils.checkNonBlank(username, "username");
ValidationUtils.checkNonBlank(password, "password");
Comment thread
chernser marked this conversation as resolved.
if (!hasUserPassword) {
throw new ClientMisconfigurationException(AUTH_CANNOT_BE_SWITCHED_ERR_MSG);
}
updateBackedConfig(username, password, false, null);
}

/**
* Replaces the current credentials with a bearer token.
*
* <p>Updates are applied atomically and take effect for newly initiated requests
* without blocking or requiring external synchronization.
*/
public void setAccessToken(String accessToken) {
if (!hasAccessToken) {
throw new ClientMisconfigurationException(AUTH_CANNOT_BE_SWITCHED_ERR_MSG);
}
ValidationUtils.checkNonBlank(accessToken, "accessToken");
updateBackedConfig(null, null, false, accessToken);
Comment thread
chernser marked this conversation as resolved.
}

private void updateBackedConfig(String username, String password, boolean useSslAuth, String authHeader) {
Map<String, Object> updated = new HashMap<>();
updated.put(ClientConfigProperties.USER.getKey(), username);
updated.put(ClientConfigProperties.PASSWORD.getKey(), password);
updated.put(ClientConfigProperties.SSL_AUTH.getKey(), useSslAuth);
updated.put(AUTHORIZATION_HEADER_KEY, authHeader);
authConfig.set(updated);
}

private static final String NO_AUTH_ERR_MSG = "Auth configuration is missing. At least one the following should be provided: " +
"user & password, access token, custom authentication headers";

private static final String ONLY_ONE_METHOD_ERR_MSG = "Only one of password, access token or SSL authentication can be used per client.";

private static final String SSL_REQUIRES_CERT_ERR_MSG = "SSL authentication requires a client certificate";

private static final String PASSWORD_AND_AUTH_HEADER_BASIC_AUTH_ERR_MSG = "When both password and authentication header is set then basic auth. should be disabled";

private boolean isUserPassword(Map<String, ?> config) {
String username = (String) config.get(ClientConfigProperties.USER.getKey());
boolean hasUser = ClientUtils.isNotBlank(username);
String password = (String) config.get(ClientConfigProperties.PASSWORD.getKey());
return hasUser && password != null;
}

private boolean isSslAuth(Map<String, ?> config) {
boolean useSslAuth = MapUtils.getFlag(config, ClientConfigProperties.SSL_AUTH.getKey(), false);
if (useSslAuth && !config.containsKey(ClientConfigProperties.SSL_CERTIFICATE.getKey())) {
throw new ClientMisconfigurationException(SSL_REQUIRES_CERT_ERR_MSG);
}
return useSslAuth;
}

private boolean isAccessToken(Map<String, ?> config) {
String accessToken = (String) config.get(ClientConfigProperties.ACCESS_TOKEN.getKey());
return ClientUtils.isNotBlank(accessToken);
}

private boolean isCustomAuthHeader(Map<String, ?> config) {
String authHeader = (String) config.get(AUTHORIZATION_HEADER_KEY);
return ClientUtils.isNotBlank(authHeader);
}
}
Loading
Loading