Skip to content

Commit 7a00ad5

Browse files
bluestreak01claude
andcommitted
Add auth and TLS support to QwpQueryClient
Auth helpers mirror the existing Sender conventions: - withBasicAuth(user, pass): sets Authorization: Basic <base64>, so a Postgres user created via CREATE USER ... WITH PASSWORD ... can log in unchanged. - withBearerToken(token): sets Authorization: Bearer <token> for OIDC access tokens. TLS helpers route through the existing WebSocketClientFactory.newTlsInstance path already used by QwpWebSocketSender: - withTls(): TLS with full validation and the JVM default trust store. - withInsecureTls(): TLS with certificate validation disabled (testing). - withTrustStore(path, password): TLS with a custom PKCS12/JKS store. fromConfig now accepts the wss:// schema and three additional keys that match the ingress Sender's config-string grammar: tls_verify=on|unsafe_off, tls_roots=<path>, tls_roots_password=<secret>. Auth and TLS keys are mutually validated so inconsistent combinations fail at build time rather than at connect. No changes to the wire protocol: the Authorization header is sent on the WebSocket upgrade request, which is the point at which the HTTP server's existing auth chain runs. Server-side support was already in place; this commit only exposes ergonomic client APIs for it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f843ad8 commit 7a00ad5

1 file changed

Lines changed: 174 additions & 8 deletions

File tree

core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java

Lines changed: 174 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
package io.questdb.client.cutlass.qwp.client;
2626

27+
import io.questdb.client.ClientTlsConfiguration;
2728
import io.questdb.client.cutlass.http.client.HttpClientException;
2829
import io.questdb.client.cutlass.http.client.WebSocketClient;
2930
import io.questdb.client.cutlass.http.client.WebSocketClientFactory;
@@ -35,6 +36,9 @@
3536
import org.slf4j.Logger;
3637
import org.slf4j.LoggerFactory;
3738

39+
import java.nio.charset.StandardCharsets;
40+
import java.util.Base64;
41+
3842
/**
3943
* QWP egress (query results) client.
4044
* <p>
@@ -113,6 +117,11 @@ public class QwpQueryClient implements QuietCloseable {
113117
// hit the timeout branch in under a second instead of spending five.
114118
@SuppressWarnings("FieldMayBeFinal")
115119
private volatile long shutdownJoinMs = 5_000;
120+
private boolean tlsEnabled;
121+
// Only meaningful when tlsEnabled. Default is full validation against the JVM's trust store.
122+
private int tlsValidationMode = ClientTlsConfiguration.TLS_VALIDATION_MODE_FULL;
123+
private char[] trustStorePassword;
124+
private String trustStorePath;
116125
private WebSocketClient webSocketClient;
117126

118127
private QwpQueryClient(CharSequence host, int port) {
@@ -126,13 +135,23 @@ private QwpQueryClient(CharSequence host, int port) {
126135
* <p>
127136
* Supported schemas:
128137
* <ul>
129-
* <li>{@code ws::} -- plain WebSocket (matches QWP egress today; TLS not yet supported).</li>
138+
* <li>{@code ws::} -- plain WebSocket.</li>
139+
* <li>{@code wss::} -- WebSocket over TLS. See {@code tls_verify} / {@code tls_roots} /
140+
* {@code tls_roots_password} for trust configuration.</li>
130141
* </ul>
131142
* Supported keys:
132143
* <ul>
133144
* <li>{@code addr=host[:port]} -- required. Default port is {@value #DEFAULT_WS_PORT}.</li>
134145
* <li>{@code path=/read/v1} -- egress endpoint. Default {@value #DEFAULT_ENDPOINT_PATH}.</li>
135-
* <li>{@code auth=<value>} -- sent as the HTTP {@code Authorization} header during the upgrade handshake.</li>
146+
* <li>{@code auth=<value>} -- sent verbatim as the HTTP {@code Authorization} header during the upgrade handshake.
147+
* Mutually exclusive with {@code username}/{@code password} and {@code token}.</li>
148+
* <li>{@code username=<name>;password=<secret>} -- HTTP Basic authentication. Server verifies the credentials
149+
* against the same user store the Postgres wire protocol uses, so a user created via
150+
* {@code CREATE USER ... WITH PASSWORD ...} can log in unchanged.
151+
* Both keys must be present together; mutually exclusive with {@code auth} and {@code token}.</li>
152+
* <li>{@code token=<access_token>} -- HTTP Bearer authentication with an OIDC access token (sent as
153+
* {@code Authorization: Bearer <token>}). Mutually exclusive with {@code auth} and
154+
* {@code username}/{@code password}.</li>
136155
* <li>{@code client_id=<id>} -- sent as the {@code X-QWP-Client-Id} header.</li>
137156
* <li>{@code buffer_pool_size=N} -- depth of the I/O thread's batch buffer pool. Default 4.</li>
138157
* <li>{@code compression=zstd|raw|auto} -- compression codec the client
@@ -141,6 +160,12 @@ private QwpQueryClient(CharSequence host, int port) {
141160
* when it supports it and falls back to raw otherwise.</li>
142161
* <li>{@code compression_level=N} -- zstd level hint, clamped server-side
143162
* to [1, 9]. Default 3. Ignored when {@code compression=raw}.</li>
163+
* <li>{@code tls_verify=on|unsafe_off} -- TLS certificate validation. Default is {@code on}.
164+
* Only allowed with the {@code wss::} schema. {@code unsafe_off} disables hostname and
165+
* certificate chain validation; use only for testing.</li>
166+
* <li>{@code tls_roots=<path>} -- path to a custom trust store (PKCS12 or JKS). Must be
167+
* paired with {@code tls_roots_password}. Only allowed with {@code wss::}.</li>
168+
* <li>{@code tls_roots_password=<secret>} -- password for the custom trust store.</li>
144169
* </ul>
145170
* Examples:
146171
* <pre>
@@ -157,25 +182,34 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) {
157182
if (pos < 0) {
158183
throw new IllegalArgumentException("invalid configuration string: " + sink);
159184
}
160-
if (Chars.equals("wss", sink)) {
161-
throw new IllegalArgumentException("wss:: (TLS) is not supported by QwpQueryClient yet");
162-
}
163-
if (!Chars.equals("ws", sink)) {
185+
boolean tls;
186+
if (Chars.equals("ws", sink)) {
187+
tls = false;
188+
} else if (Chars.equals("wss", sink)) {
189+
tls = true;
190+
} else {
164191
throw new IllegalArgumentException(
165-
"unsupported schema [schema=" + sink + ", supported-schemas=[ws]]");
192+
"unsupported schema [schema=" + sink + ", supported-schemas=[ws, wss]]");
166193
}
167194

168195
String addrHost = null;
169196
int addrPort = DEFAULT_WS_PORT;
170197
String path = DEFAULT_ENDPOINT_PATH;
171198
String auth = null;
199+
String username = null;
200+
String password = null;
201+
String token = null;
172202
String cid = null;
173203
int poolSize = DEFAULT_IO_BUFFER_POOL_SIZE;
174204
// Default matches the field initializer in QwpQueryClient: raw wire,
175205
// zstd opt-in.
176206
String compression = "raw";
177207
int compressionLevel = 3;
178208
int maxBatchRows = 0; // 0 = omit header, server uses its default
209+
// TLS validation mode: null means "unset in config". Explicit values kick in only when tls is true.
210+
Integer tlsValidation = null;
211+
String tlsRoots = null;
212+
String tlsRootsPassword = null;
179213

180214
while (ConfStringParser.hasNext(configurationString, pos)) {
181215
pos = ConfStringParser.nextKey(configurationString, pos, sink);
@@ -209,6 +243,15 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) {
209243
case "auth":
210244
auth = value;
211245
break;
246+
case "username":
247+
username = value;
248+
break;
249+
case "password":
250+
password = value;
251+
break;
252+
case "token":
253+
token = value;
254+
break;
212255
case "client_id":
213256
cid = value;
214257
break;
@@ -250,18 +293,62 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) {
250293
"max_batch_rows must be in [1, " + MAX_BATCH_ROWS_UPPER_BOUND + "]");
251294
}
252295
break;
296+
case "tls_verify":
297+
if ("on".equals(value)) {
298+
tlsValidation = ClientTlsConfiguration.TLS_VALIDATION_MODE_FULL;
299+
} else if ("unsafe_off".equals(value)) {
300+
tlsValidation = ClientTlsConfiguration.TLS_VALIDATION_MODE_NONE;
301+
} else {
302+
throw new IllegalArgumentException(
303+
"invalid tls_verify: " + value + " (expected on or unsafe_off)");
304+
}
305+
break;
306+
case "tls_roots":
307+
tlsRoots = value;
308+
break;
309+
case "tls_roots_password":
310+
tlsRootsPassword = value;
311+
break;
253312
default:
254313
throw new IllegalArgumentException("unknown configuration key: " + key);
255314
}
256315
}
257316
if (addrHost == null) {
258317
throw new IllegalArgumentException("missing required key: addr");
259318
}
319+
boolean hasBasic = username != null || password != null;
320+
if (hasBasic && (username == null || password == null)) {
321+
throw new IllegalArgumentException("both username and password must be provided together");
322+
}
323+
int authModesSet = (auth != null ? 1 : 0) + (hasBasic ? 1 : 0) + (token != null ? 1 : 0);
324+
if (authModesSet > 1) {
325+
throw new IllegalArgumentException(
326+
"auth, username/password, and token are mutually exclusive");
327+
}
328+
if (!tls && (tlsValidation != null || tlsRoots != null || tlsRootsPassword != null)) {
329+
throw new IllegalArgumentException(
330+
"tls_verify/tls_roots/tls_roots_password require the wss:: schema");
331+
}
332+
if ((tlsRoots == null) != (tlsRootsPassword == null)) {
333+
throw new IllegalArgumentException(
334+
"tls_roots and tls_roots_password must be provided together");
335+
}
260336
QwpQueryClient client = new QwpQueryClient(addrHost, addrPort)
261337
.withEndpointPath(path)
262338
.withBufferPoolSize(poolSize)
263339
.withCompression(compression, compressionLevel);
340+
if (tls) {
341+
if (tlsRoots != null) {
342+
client.withTrustStore(tlsRoots, tlsRootsPassword.toCharArray());
343+
} else if (tlsValidation != null && tlsValidation == ClientTlsConfiguration.TLS_VALIDATION_MODE_NONE) {
344+
client.withInsecureTls();
345+
} else {
346+
client.withTls();
347+
}
348+
}
264349
if (auth != null) client.withAuthorization(auth);
350+
if (hasBasic) client.withBasicAuth(username, password);
351+
if (token != null) client.withBearerToken(token);
265352
if (cid != null) client.withClientId(cid);
266353
if (maxBatchRows > 0) client.withMaxBatchRows(maxBatchRows);
267354
return client;
@@ -355,7 +442,12 @@ public void connect() {
355442
if (connected) {
356443
return;
357444
}
358-
webSocketClient = WebSocketClientFactory.newPlainTextInstance();
445+
if (tlsEnabled) {
446+
webSocketClient = WebSocketClientFactory.newTlsInstance(
447+
new ClientTlsConfiguration(trustStorePath, trustStorePassword, tlsValidationMode));
448+
} else {
449+
webSocketClient = WebSocketClientFactory.newPlainTextInstance();
450+
}
359451
webSocketClient.setQwpMaxVersion(QWP_MAX_VERSION);
360452
webSocketClient.setQwpClientId(clientId != null ? clientId : defaultClientId());
361453
webSocketClient.setQwpAcceptEncoding(buildAcceptEncodingHeader());
@@ -472,6 +564,37 @@ public QwpQueryClient withAuthorization(String authorizationHeader) {
472564
return this;
473565
}
474566

567+
/**
568+
* Configures HTTP Basic authentication for the WebSocket upgrade request.
569+
* The server verifies the credentials against the same user store the
570+
* Postgres wire protocol uses, so a user created via
571+
* {@code CREATE USER ... WITH PASSWORD ...} can authenticate here unchanged.
572+
* Must be called before {@link #connect}.
573+
*/
574+
public QwpQueryClient withBasicAuth(String username, String password) {
575+
if (username == null || password == null) {
576+
throw new IllegalArgumentException("username and password must not be null");
577+
}
578+
String credentials = username + ":" + password;
579+
this.authorizationHeader = "Basic " + Base64.getEncoder()
580+
.encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
581+
return this;
582+
}
583+
584+
/**
585+
* Configures HTTP Bearer authentication with an OIDC access token for the
586+
* WebSocket upgrade request. The server verifies the token via the
587+
* configured OIDC provider and resolves the principal (and any groups)
588+
* from the token's claims. Must be called before {@link #connect}.
589+
*/
590+
public QwpQueryClient withBearerToken(String token) {
591+
if (token == null) {
592+
throw new IllegalArgumentException("token must not be null");
593+
}
594+
this.authorizationHeader = "Bearer " + token;
595+
return this;
596+
}
597+
475598
/**
476599
* Overrides the default I/O buffer pool depth (4). Larger pools let the
477600
* I/O thread decode further ahead of the consumer at the cost of memory;
@@ -534,6 +657,19 @@ public QwpQueryClient withInitialCredit(long bytes) {
534657
return this;
535658
}
536659

660+
/**
661+
* Enables TLS with certificate validation disabled. Intended for testing only --
662+
* production code should use {@link #withTls} or {@link #withTrustStore}.
663+
* Must be called before {@link #connect}.
664+
*/
665+
public QwpQueryClient withInsecureTls() {
666+
this.tlsEnabled = true;
667+
this.tlsValidationMode = ClientTlsConfiguration.TLS_VALIDATION_MODE_NONE;
668+
this.trustStorePath = null;
669+
this.trustStorePassword = null;
670+
return this;
671+
}
672+
537673
/**
538674
* Asks the server to cap each {@code RESULT_BATCH} at {@code rows} rows.
539675
* Useful for latency-sensitive streaming consumers that want to start
@@ -554,6 +690,36 @@ public QwpQueryClient withMaxBatchRows(int rows) {
554690
return this;
555691
}
556692

693+
/**
694+
* Enables TLS with full certificate validation against the JVM's default trust store.
695+
* Must be called before {@link #connect}.
696+
*/
697+
public QwpQueryClient withTls() {
698+
this.tlsEnabled = true;
699+
this.tlsValidationMode = ClientTlsConfiguration.TLS_VALIDATION_MODE_FULL;
700+
this.trustStorePath = null;
701+
this.trustStorePassword = null;
702+
return this;
703+
}
704+
705+
/**
706+
* Enables TLS with full certificate validation against the given custom trust store.
707+
* Must be called before {@link #connect}.
708+
*
709+
* @param trustStorePath filesystem path to a PKCS12 or JKS trust store
710+
* @param trustStorePassword password for the trust store
711+
*/
712+
public QwpQueryClient withTrustStore(String trustStorePath, char[] trustStorePassword) {
713+
if (trustStorePath == null || trustStorePassword == null) {
714+
throw new IllegalArgumentException("trustStorePath and trustStorePassword must not be null");
715+
}
716+
this.tlsEnabled = true;
717+
this.tlsValidationMode = ClientTlsConfiguration.TLS_VALIDATION_MODE_FULL;
718+
this.trustStorePath = trustStorePath;
719+
this.trustStorePassword = trustStorePassword;
720+
return this;
721+
}
722+
557723
private static String defaultClientId() {
558724
return "questdb-java-egress/1.0.0";
559725
}

0 commit comments

Comments
 (0)