Skip to content

Commit 00e534d

Browse files
authored
Merge pull request #2812 from ClickHouse/03/30/26/implement_change_credentials
[client-v2,jdbc-v2] Change credentials in realtime
2 parents ca644e1 + 4520f45 commit 00e534d

14 files changed

Lines changed: 1033 additions & 50 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,20 @@
1717

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

20+
### Breaking Changes
21+
22+
- **[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`).
23+
24+
- **[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.
25+
26+
- **[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.
27+
28+
- **[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.
29+
2030
### New Features
2131

32+
- **[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.
33+
2234
- **[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)
2335

2436
- **[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)

client-v2/src/main/java/com/clickhouse/client/api/Client.java

Lines changed: 55 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import com.clickhouse.client.api.insert.InsertResponse;
1717
import com.clickhouse.client.api.insert.InsertSettings;
1818
import com.clickhouse.client.api.internal.ClientStatisticsHolder;
19+
import com.clickhouse.client.api.internal.CredentialsManager;
1920
import com.clickhouse.client.api.internal.HttpAPIClientHelper;
2021
import com.clickhouse.client.api.internal.MapUtils;
2122
import com.clickhouse.client.api.internal.TableSchemaParser;
@@ -44,7 +45,6 @@
4445
import org.apache.hc.core5.concurrent.DefaultThreadFactory;
4546
import org.apache.hc.core5.http.ClassicHttpResponse;
4647
import org.apache.hc.core5.http.Header;
47-
import org.apache.hc.core5.http.HttpHeaders;
4848
import org.apache.hc.core5.http.HttpStatus;
4949
import org.slf4j.Logger;
5050
import org.slf4j.LoggerFactory;
@@ -105,7 +105,8 @@
105105
*
106106
*
107107
*
108-
* <p>Client is thread-safe. It uses exclusive set of object to perform an operation.</p>
108+
* <p>Client is thread-safe. It uses exclusive set of object to perform an operation.
109+
* Exception is client global authentication configuration. Application should handle it in the way it is designed.</p>
109110
*
110111
*/
111112
public class Client implements AutoCloseable {
@@ -140,11 +141,13 @@ public class Client implements AutoCloseable {
140141
private final int retries;
141142
private LZ4Factory lz4Factory = null;
142143
private final Supplier<String> queryIdGenerator;
144+
private final CredentialsManager credentialsManager;
143145

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

@@ -1128,28 +1132,12 @@ public Client build() {
11281132
if (this.endpoints.isEmpty()) {
11291133
throw new IllegalArgumentException("At least one endpoint is required");
11301134
}
1131-
// check if username and password are empty. so can not initiate client?
1132-
boolean useSslAuth = MapUtils.getFlag(this.configuration, ClientConfigProperties.SSL_AUTH.getKey());
1133-
boolean hasAccessToken = this.configuration.containsKey(ClientConfigProperties.ACCESS_TOKEN.getKey());
1134-
boolean hasUser = this.configuration.containsKey(ClientConfigProperties.USER.getKey());
1135-
boolean hasPassword = this.configuration.containsKey(ClientConfigProperties.PASSWORD.getKey());
1136-
boolean customHttpHeaders = this.configuration.containsKey(ClientConfigProperties.httpHeader(HttpHeaders.AUTHORIZATION));
1137-
1138-
if (!(useSslAuth || hasAccessToken || hasUser || hasPassword || customHttpHeaders)) {
1139-
throw new IllegalArgumentException("Username and password (or access token or SSL authentication or pre-define Authorization header) are required");
1140-
}
1141-
1142-
if (useSslAuth && (hasAccessToken || hasPassword)) {
1143-
throw new IllegalArgumentException("Only one of password, access token or SSL authentication can be used per client.");
1144-
}
11451135

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

1150-
if (this.configuration.containsKey(ClientConfigProperties.SSL_TRUST_STORE.getKey()) &&
1151-
this.configuration.containsKey(ClientConfigProperties.SSL_CERTIFICATE.getKey())) {
1152-
throw new IllegalArgumentException("Trust store and certificates cannot be used together");
1138+
if (configuration.containsKey(ClientConfigProperties.SSL_TRUST_STORE.getKey()) &&
1139+
configuration.containsKey(ClientConfigProperties.SSL_CERTIFICATE.getKey())) {
1140+
throw new ClientMisconfigurationException("Trust store and certificates cannot be used together");
11531141
}
11541142

11551143
// Check timezone settings
@@ -1181,7 +1169,7 @@ public Client build() {
11811169
}
11821170

11831171
return new Client(this.endpoints, this.configuration, this.sharedOperationExecutor,
1184-
this.columnToMethodMatchingStrategy, this.metricRegistry, this.queryIdGenerator);
1172+
this.columnToMethodMatchingStrategy, this.metricRegistry, this.queryIdGenerator, cManager);
11851173
}
11861174
}
11871175

@@ -2113,7 +2101,8 @@ public String toString() {
21132101
}
21142102

21152103
/**
2116-
* Returns unmodifiable map of configuration options.
2104+
* Returns unmodifiable map of initial configuration options.
2105+
* As authentication configuration values can change this map doesn't reflect them.
21172106
* @return - configuration options
21182107
*/
21192108
public Map<String, String> getConfiguration() {
@@ -2193,8 +2182,44 @@ public Collection<String> getDBRoles() {
21932182
return unmodifiableDbRolesView;
21942183
}
21952184

2185+
2186+
/**
2187+
* Updates Bearer token for other requests.
2188+
* This method is not thread-safe with respect to other credential updates
2189+
* or concurrent request execution. Applications must coordinate access if
2190+
* they require stronger consistency.
2191+
* Method doesn't allow to switch authentication type
2192+
* @param bearer - token to use without {@code "Bearer"} prefix.
2193+
*/
21962194
public void updateBearerToken(String bearer) {
2197-
this.configuration.put(ClientConfigProperties.httpHeader(HttpHeaders.AUTHORIZATION), "Bearer " + bearer);
2195+
ValidationUtils.checkNonBlank(bearer, "Bearer token");
2196+
updateAccessToken(CredentialsManager.AUTH_HEADER_BEARER_PREFIX + bearer);
2197+
}
2198+
2199+
/**
2200+
* Updates the user and password for all subsequential requests.
2201+
* This method is not thread-safe with respect to other credential updates
2202+
* or concurrent request execution. Applications must coordinate access if
2203+
* they require stronger consistency.
2204+
* Method doesn't allow to switch authentication type
2205+
* @param username user name
2206+
* @param password user password
2207+
* @throws ClientMisconfigurationException if another authentication type in use.
2208+
*/
2209+
public void updateUserAndPassword(String username, String password) {
2210+
this.credentialsManager.setCredentials(username, password);
2211+
}
2212+
2213+
/**
2214+
* Updates access token for the client. Change will be applied to all following requests.
2215+
* This method is not thread-safe with respect to other credential updates
2216+
* or concurrent request execution. Applications must coordinate access if
2217+
* they require stronger consistency.
2218+
* Method doesn't allow to switch authentication type
2219+
* @param accessToken - plain text access token
2220+
*/
2221+
public void updateAccessToken(String accessToken) {
2222+
this.credentialsManager.setAccessToken(accessToken);
21982223
}
21992224

22002225
private Endpoint getNextAliveNode() {
@@ -2212,6 +2237,7 @@ private Endpoint getNextAliveNode() {
22122237
private Map<String, Object> buildRequestSettings(Map<String, Object> opSettings) {
22132238
Map<String, Object> requestSettings = new HashMap<>(configuration);
22142239
session.applyTo(requestSettings);
2240+
credentialsManager.applyCredentials(requestSettings);
22152241
requestSettings.putAll(opSettings);
22162242
return requestSettings;
22172243
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.clickhouse.client.api.internal;
2+
3+
/**
4+
* Class containing utility methods used across the client.
5+
*/
6+
public final class ClientUtils {
7+
8+
private ClientUtils() {}
9+
10+
public static boolean isNotBlank(String str) {
11+
return str != null && !str.trim().isEmpty();
12+
}
13+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package com.clickhouse.client.api.internal;
2+
3+
import com.clickhouse.client.api.ClientConfigProperties;
4+
import com.clickhouse.client.api.ClientMisconfigurationException;
5+
import org.apache.hc.core5.http.HttpHeaders;
6+
7+
import java.util.Arrays;
8+
import java.util.HashMap;
9+
import java.util.Map;
10+
import java.util.concurrent.atomic.AtomicReference;
11+
12+
/**
13+
* Manages mutable authentication-related client settings.
14+
* This class uses an atomic reference to ensure thread-safe credential updates.
15+
* Updates are non-blocking and will be applied to newly initiated requests
16+
* immediately without affecting ongoing queries.
17+
*/
18+
public class CredentialsManager {
19+
20+
public static final String AUTHORIZATION_HEADER_KEY =
21+
ClientConfigProperties.httpHeader(HttpHeaders.AUTHORIZATION);
22+
public static final String AUTH_HEADER_BEARER_PREFIX = "Bearer ";
23+
24+
private final AtomicReference<Map<String, Object>> authConfig = new AtomicReference<>();
25+
26+
private final boolean hasUserPassword;
27+
28+
private final boolean hasAccessToken;
29+
30+
public CredentialsManager(Map<String, String> config) {
31+
this.hasUserPassword = isUserPassword(config);
32+
this.hasAccessToken = isAccessToken(config);
33+
34+
boolean sslAuthEnabled = isSslAuth(config);
35+
boolean hasAuthHeader = isCustomAuthHeader(config);
36+
37+
final long authMethodsCount = Arrays
38+
.stream(new Boolean[]{hasUserPassword, hasAccessToken, sslAuthEnabled, hasAuthHeader})
39+
.filter(b -> b).count();
40+
41+
String username = config.get(ClientConfigProperties.USER.getKey());
42+
if (authMethodsCount == 1 && !hasAuthHeader) {
43+
// Auth header handled specially
44+
String password = config.getOrDefault(ClientConfigProperties.PASSWORD.getKey(), "");
45+
boolean useSslAuth = MapUtils.getFlag(config, ClientConfigProperties.SSL_AUTH.getKey(), false);
46+
String accessToken = config.get(ClientConfigProperties.ACCESS_TOKEN.getKey());
47+
updateBackedConfig(username, password, useSslAuth, accessToken);
48+
} else if (authMethodsCount == 0 && ClientUtils.isNotBlank(username)) {
49+
// password not set - it is still user, password case if no other auth
50+
String password = config.getOrDefault(ClientConfigProperties.PASSWORD.getKey(), "");
51+
updateBackedConfig(username, password, false, null);
52+
} else if (authMethodsCount == 0) {
53+
throw new ClientMisconfigurationException(NO_AUTH_ERR_MSG);
54+
} else if (hasAuthHeader) {
55+
if (hasUserPassword && MapUtils.getFlag(config, ClientConfigProperties.HTTP_USE_BASIC_AUTH.getKey(),
56+
ClientConfigProperties.HTTP_USE_BASIC_AUTH.getDefObjVal())) {
57+
throw new ClientMisconfigurationException(PASSWORD_AND_AUTH_HEADER_BASIC_AUTH_ERR_MSG);
58+
}
59+
String password = config.getOrDefault(ClientConfigProperties.PASSWORD.getKey(), "");
60+
String authHeader = config.getOrDefault(AUTHORIZATION_HEADER_KEY, "");
61+
updateBackedConfig(username, password, false, authHeader);
62+
} else {
63+
throw new ClientMisconfigurationException(ONLY_ONE_METHOD_ERR_MSG);
64+
}
65+
}
66+
67+
public void applyCredentials(Map<String, Object> target) {
68+
Map<String, Object> properties = authConfig.get();
69+
target.putAll(properties);
70+
}
71+
72+
private static final String AUTH_CANNOT_BE_SWITCHED_ERR_MSG = "Authentication type cannot be switched at runtime";
73+
74+
/**
75+
* Replaces the current username/password credentials.
76+
*
77+
* <p>Updates are applied atomically and take effect for newly initiated requests
78+
* without blocking or requiring external synchronization.
79+
*/
80+
public void setCredentials(String username, String password) {
81+
ValidationUtils.checkNonBlank(username, "username");
82+
ValidationUtils.checkNonBlank(password, "password");
83+
if (!hasUserPassword) {
84+
throw new ClientMisconfigurationException(AUTH_CANNOT_BE_SWITCHED_ERR_MSG);
85+
}
86+
updateBackedConfig(username, password, false, null);
87+
}
88+
89+
/**
90+
* Replaces the current credentials with a bearer token.
91+
*
92+
* <p>Updates are applied atomically and take effect for newly initiated requests
93+
* without blocking or requiring external synchronization.
94+
*/
95+
public void setAccessToken(String accessToken) {
96+
if (!hasAccessToken) {
97+
throw new ClientMisconfigurationException(AUTH_CANNOT_BE_SWITCHED_ERR_MSG);
98+
}
99+
ValidationUtils.checkNonBlank(accessToken, "accessToken");
100+
updateBackedConfig(null, null, false, accessToken);
101+
}
102+
103+
private void updateBackedConfig(String username, String password, boolean useSslAuth, String authHeader) {
104+
Map<String, Object> updated = new HashMap<>();
105+
updated.put(ClientConfigProperties.USER.getKey(), username);
106+
updated.put(ClientConfigProperties.PASSWORD.getKey(), password);
107+
updated.put(ClientConfigProperties.SSL_AUTH.getKey(), useSslAuth);
108+
updated.put(AUTHORIZATION_HEADER_KEY, authHeader);
109+
authConfig.set(updated);
110+
}
111+
112+
private static final String NO_AUTH_ERR_MSG = "Auth configuration is missing. At least one the following should be provided: " +
113+
"user & password, access token, custom authentication headers";
114+
115+
private static final String ONLY_ONE_METHOD_ERR_MSG = "Only one of password, access token or SSL authentication can be used per client.";
116+
117+
private static final String SSL_REQUIRES_CERT_ERR_MSG = "SSL authentication requires a client certificate";
118+
119+
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";
120+
121+
private boolean isUserPassword(Map<String, ?> config) {
122+
String username = (String) config.get(ClientConfigProperties.USER.getKey());
123+
boolean hasUser = ClientUtils.isNotBlank(username);
124+
String password = (String) config.get(ClientConfigProperties.PASSWORD.getKey());
125+
return hasUser && password != null;
126+
}
127+
128+
private boolean isSslAuth(Map<String, ?> config) {
129+
boolean useSslAuth = MapUtils.getFlag(config, ClientConfigProperties.SSL_AUTH.getKey(), false);
130+
if (useSslAuth && !config.containsKey(ClientConfigProperties.SSL_CERTIFICATE.getKey())) {
131+
throw new ClientMisconfigurationException(SSL_REQUIRES_CERT_ERR_MSG);
132+
}
133+
return useSslAuth;
134+
}
135+
136+
private boolean isAccessToken(Map<String, ?> config) {
137+
String accessToken = (String) config.get(ClientConfigProperties.ACCESS_TOKEN.getKey());
138+
return ClientUtils.isNotBlank(accessToken);
139+
}
140+
141+
private boolean isCustomAuthHeader(Map<String, ?> config) {
142+
String authHeader = (String) config.get(AUTHORIZATION_HEADER_KEY);
143+
return ClientUtils.isNotBlank(authHeader);
144+
}
145+
}

0 commit comments

Comments
 (0)