Skip to content

Commit 3d10a4e

Browse files
glasstigerclaude
andcommitted
QWP egress token provider and OIDC API rename
Add QwpQueryClient.withBearerTokenProvider(HttpTokenProvider): the egress query client now accepts an on-demand token provider, the same HttpTokenProvider the ingress Sender uses, so OidcDeviceAuth::getToken plugs into both. runUpgradeWithTimeout resolves the Authorization header at every WebSocket upgrade, so the initial connect and each failover reconnect present a freshly refreshed token; a throwing provider fails that connection attempt, matching the ingress sender. The setter is mutually exclusive with withBearerToken/withBasicAuth and validates each token before it reaches the header. Rename the OidcDeviceAuth methods so the safe, non-blocking accessor takes the plain name and the interactive step is explicit: getToken() is now signIn() (interactive sign-in, may block); getTokenSilently() is now getToken() (cached/refresh, never prompts). getAuthorizationHeaderValue() keeps its blocking behavior by calling signIn(). Update the tests, examples, and doc references across the client. The Python client is left unchanged for now. Print the egress timestamp column as a formatted Instant in OIDCAuthExample instead of the raw microsecond long. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e1f3d53 commit 3d10a4e

11 files changed

Lines changed: 429 additions & 176 deletions

File tree

core/src/main/java/io/questdb/client/HttpTokenProvider.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929

3030
/**
3131
* Supplies an HTTP authentication token to a {@link Sender} on demand, so a provider returning a
32-
* freshly refreshed token - e.g. {@code OidcDeviceAuth::getTokenSilently} - keeps a long-lived sender
32+
* freshly refreshed token - e.g. {@code OidcDeviceAuth::getToken} - keeps a long-lived sender
3333
* authenticated as the token rotates, without rebuilding it. Over HTTP the sender calls
3434
* {@link #getToken()} as it builds each request; over WebSocket it calls it once per connection
3535
* handshake, on the initial connect and again on every reconnect.

core/src/main/java/io/questdb/client/Sender.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2018,7 +2018,7 @@ public LineSenderBuilder httpToken(String token) {
20182018
/**
20192019
* Supplies the HTTP authentication token from a provider queried as the sender builds each request,
20202020
* instead of a fixed {@link #httpToken(String) token} captured once, so a long-lived sender follows
2021-
* token refreshes - e.g. an OIDC device-flow token: {@code .httpTokenProvider(auth::getTokenSilently)}.
2021+
* token refreshes - e.g. an OIDC device-flow token: {@code .httpTokenProvider(auth::getToken)}.
20222022
* <br>
20232023
* Over HTTP the provider is not called at build time: the first call happens when the first row is
20242024
* started, then once per flush. Over WebSocket the initial connection handshake runs during

core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
* Typical use, discovering everything from the QuestDB server:
6666
* <pre>{@code
6767
* try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.com:9000")) {
68-
* String token = auth.getToken(); // signs in on first use, then caches and refreshes
68+
* String token = auth.signIn(); // signs in on first use, then caches and refreshes
6969
* // ... use token as an HTTP Bearer header or a PG-wire _sso password ...
7070
* }
7171
* }</pre>
@@ -79,11 +79,11 @@
7979
* .groupsInToken(true)
8080
* .build();
8181
* }</pre>
82-
* {@link #getToken()} serves a cached token while valid, silently refreshes when a refresh token
82+
* {@link #signIn()} serves a cached token while valid, silently refreshes when a refresh token
8383
* exists, otherwise re-runs the interactive flow. An instance lock serializes calls, so two
8484
* sign-ins never start at once. A sign-in waiting for the user holds that lock for the device code
85-
* lifetime (up to 30 minutes), so a concurrent {@link #getToken()} or {@link #clearCache()} blocks
86-
* behind it - but {@link #getTokenSilently()} never waits: it fails fast with an
85+
* lifetime (up to 30 minutes), so a concurrent {@link #signIn()} or {@link #clearCache()} blocks
86+
* behind it - but {@link #getToken()} never waits: it fails fast with an
8787
* {@link OidcAuthException} so a request/flush path never stalls. To abort a waiting sign-in, call
8888
* {@link #close()} from another thread; it signals the flow to stop, which then fails with an
8989
* {@link OidcAuthException} rather than polling until the device code expires. Cancellation is seen
@@ -153,8 +153,8 @@ public class OidcDeviceAuth implements QuietCloseable {
153153
private final StringSink formSink = new StringSink();
154154
private final boolean groupsInToken;
155155
private final int httpTimeoutMillis;
156-
// serializes getToken()/getTokenSilently()/clearCache()/close(); getToken() holds it for the whole
157-
// interactive flow, getTokenSilently() uses tryLock so the flush path never stalls behind a sign-in
156+
// serializes signIn()/getToken()/clearCache()/close(); signIn() holds it for the whole
157+
// interactive flow, getToken() uses tryLock so the flush path never stalls behind a sign-in
158158
private final ReentrantLock lock = new ReentrantLock();
159159
private final DeviceCodePrompt prompt;
160160
private final StringSink responseStatus = new StringSink();
@@ -351,7 +351,7 @@ public static OidcDeviceAuth fromQuestDB(String questdbUrl, DiscoveryOptions opt
351351
}
352352

353353
/**
354-
* Drops any cached token so the next {@link #getToken()} starts a fresh interactive sign-in.
354+
* Drops any cached token so the next {@link #signIn()} starts a fresh interactive sign-in.
355355
*/
356356
public void clearCache() {
357357
lock.lock();
@@ -368,7 +368,7 @@ public void clearCache() {
368368
}
369369

370370
/**
371-
* Frees the network connections and native buffers this instance holds. If a {@link #getToken()}
371+
* Frees the network connections and native buffers this instance holds. If a {@link #signIn()}
372372
* sign-in is in flight on another thread, signals it to stop so it fails with an
373373
* {@link OidcAuthException} instead of polling until the device code expires. The signal is observed
374374
* between polls (within ~100ms while waiting out a poll interval); a poll request already in flight
@@ -378,12 +378,12 @@ public void clearCache() {
378378
* {@link DeviceCodePrompt} that blocks in {@code promptUser} - for example the default
379379
* {@link DeviceCodePrompt#openBrowser()} prompt while it hands the verification URL to the OS browser,
380380
* which is not bounded by the HTTP timeout: the flow holds the lock across that one-off prompt, so a
381-
* racing {@code close()} waits it out too. Idempotent. After close, {@link #getToken()} and
382-
* {@link #clearCache()} throw.
381+
* racing {@code close()} waits it out too. Idempotent. After close, {@link #signIn()},
382+
* {@link #getToken()} and {@link #clearCache()} throw.
383383
*/
384384
@Override
385385
public void close() {
386-
// flag cancellation before taking the lock: getToken() holds it for the whole flow, so signal the
386+
// flag cancellation before taking the lock: signIn() holds it for the whole flow, so signal the
387387
// in-flight sign-in to stop via a lock-free volatile write, then acquire the lock - released by the
388388
// cancelled flow once it observes the flag (between polls, or after an in-flight poll returns) - and
389389
// free the native resources. close() never frees while a flow holds the lock, so no use-after-free
@@ -399,11 +399,11 @@ public void close() {
399399
}
400400

401401
/**
402-
* @return {@code "Bearer " + getToken()}, ready to use as the value of an HTTP
402+
* @return {@code "Bearer " + signIn()}, ready to use as the value of an HTTP
403403
* {@code Authorization} header.
404404
*/
405405
public String getAuthorizationHeaderValue() {
406-
return "Bearer " + getToken();
406+
return "Bearer " + signIn();
407407
}
408408

409409
/**
@@ -415,11 +415,11 @@ public String getAuthorizationHeaderValue() {
415415
* @throws OidcAuthException if the interactive flow fails, times out, or the identity provider
416416
* does not return the expected token
417417
*/
418-
public String getToken() {
418+
public String signIn() {
419419
lock.lock();
420420
try {
421421
throwIfClosed();
422-
// only the kind of token getToken() actually serves counts as a cache hit; a grant that
422+
// only the kind of token signIn() actually serves counts as a cache hit; a grant that
423423
// returned the other kind (access token when the server wants the id token, or vice versa)
424424
// leaves the served token null, so re-run the flow rather than report the unusable grant as
425425
// valid and have selectToken() throw on this and every later call
@@ -440,13 +440,13 @@ public String getToken() {
440440
}
441441

442442
/**
443-
* Like {@link #getToken()} but never starts the interactive device flow, never prompts, and never waits
443+
* Like {@link #signIn()} but never starts the interactive device flow, never prompts, and never waits
444444
* on interactive input: returns the cached token while valid, silently refreshes when a refresh token is
445445
* available, otherwise throws. Designed for the request/flush path of a long-lived client, for example
446-
* {@code Sender.builder(...).httpTokenProvider(auth::getTokenSilently)}, where an interactive prompt is
447-
* inappropriate. Call {@link #getToken()} once to sign in first.
446+
* {@code Sender.builder(...).httpTokenProvider(auth::getToken)}, where an interactive prompt is
447+
* inappropriate. Call {@link #signIn()} once to sign in first.
448448
* <p>
449-
* It does not wait behind an interactive {@link #getToken()} running on another thread (which would stall
449+
* It does not wait behind an interactive {@link #signIn()} running on another thread (which would stall
450450
* the flush for the whole device-code lifetime): if such a sign-in holds the lock it fails fast, and the
451451
* caller should retry once the sign-in completes. It is not, however, instantaneous - when the cached
452452
* token has expired it makes one synchronous refresh round-trip to the token endpoint, bounded by
@@ -458,9 +458,9 @@ public String getToken() {
458458
* not be refreshed without an interactive sign-in, or if a sign-in or
459459
* refresh is already in progress on another thread
460460
*/
461-
public String getTokenSilently() {
461+
public String getToken() {
462462
throwIfClosed();
463-
// never wait on the flush path: getToken()'s sign-in holds the lock for the whole device-code
463+
// never wait on the flush path: signIn()'s sign-in holds the lock for the whole device-code
464464
// lifetime (up to 30 minutes), so tryLock and fail fast if held. A sign-in in progress means there
465465
// is no token to serve yet, so the caller gets a prompt exception to retry rather than a stalled
466466
// flush
@@ -477,9 +477,9 @@ public String getTokenSilently() {
477477
if (refreshToken != null && tryRefresh()) {
478478
return selectToken();
479479
}
480-
throw new OidcAuthException("the cached token expired and could not be refreshed without an interactive sign-in; call getToken() to sign in again");
480+
throw new OidcAuthException("the cached token expired and could not be refreshed without an interactive sign-in; call signIn() to sign in again");
481481
}
482-
throw new OidcAuthException("no token has been obtained yet; call getToken() to sign in before using getTokenSilently()");
482+
throw new OidcAuthException("no token has been obtained yet; call signIn() to sign in before using getToken()");
483483
} finally {
484484
lock.unlock();
485485
}

core/src/main/java/io/questdb/client/cutlass/line/http/AbstractLineHttpSender.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,7 @@ public static AbstractLineHttpSender createLineSender(
412412
if (httpTokenProvider != null) {
413413
// The constructor already built the initial request without a token. Defer the first
414414
// getToken() off this build path to the first row (table()), so a provider that signs in
415-
// lazily - e.g. OidcDeviceAuth::getTokenSilently - can be wired before sign-in completes,
415+
// lazily - e.g. OidcDeviceAuth::getToken - can be wired before sign-in completes,
416416
// and the token pull stays on the use/flush path the provider documents.
417417
sender.httpTokenProvider = httpTokenProvider;
418418
sender.isTokenPending = true;
@@ -816,7 +816,7 @@ private boolean rowAdded() {
816816
private void stampTokenIfPending() {
817817
if (isTokenPending) {
818818
// The construct/flush path deferred the token so a lazily-signing-in provider (e.g.
819-
// OidcDeviceAuth::getTokenSilently) could be wired before sign-in completed, and so a provider
819+
// OidcDeviceAuth::getToken) could be wired before sign-in completed, and so a provider
820820
// failure never strikes after a successful send. The caller is now starting the first row, so
821821
// rebuild the still-empty request to carry the token before any row data goes in. Clear the
822822
// flag only after newRequest(true) succeeds: a pull that throws (not signed in yet, or a failed

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

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
package io.questdb.client.cutlass.qwp.client;
2626

2727
import io.questdb.client.ClientTlsConfiguration;
28+
import io.questdb.client.HttpTokenProvider;
2829
import io.questdb.client.cutlass.http.client.HttpClientException;
2930
import io.questdb.client.cutlass.http.client.WebSocketClient;
3031
import io.questdb.client.cutlass.http.client.WebSocketClientFactory;
@@ -285,6 +286,11 @@ public class QwpQueryClient implements QuietCloseable {
285286
private boolean tlsEnabled;
286287
// Only meaningful when tlsEnabled. Default is full validation against the JVM's trust store.
287288
private int tlsValidationMode = ClientTlsConfiguration.TLS_VALIDATION_MODE_FULL;
289+
// Supplies a fresh Bearer token at each WebSocket upgrade (the initial
290+
// connect and every failover reconnect), so a long-lived client follows
291+
// token rotation. Mutually exclusive with the fixed authorizationHeader
292+
// synthesized by withBearerToken/withBasicAuth; null when unset.
293+
private HttpTokenProvider tokenProvider;
288294
private char[] trustStorePassword;
289295
private String trustStorePath;
290296
private volatile WebSocketClient webSocketClient;
@@ -838,11 +844,12 @@ public int getCompressionLevelForTest() {
838844
/**
839845
* Test-only hook: the synthesized {@code Authorization} header value
840846
* ({@code Basic ...} or {@code Bearer ...}), or null when no credentials
841-
* were configured.
847+
* were configured. When a token provider is configured, queries it and
848+
* validates the returned token, exactly as a real upgrade would.
842849
*/
843850
@TestOnly
844851
public String getAuthorizationHeaderForTest() {
845-
return authorizationHeader;
852+
return resolveAuthorizationHeader();
846853
}
847854

848855
/**
@@ -1003,6 +1010,9 @@ public QwpQueryClient withAuthTimeout(long authTimeoutMs) {
10031010
*/
10041011
public QwpQueryClient withBasicAuth(String username, String password) {
10051012
checkPreConnect("withBasicAuth");
1013+
if (tokenProvider != null) {
1014+
throw new IllegalStateException("withBasicAuth cannot be combined with withBearerTokenProvider");
1015+
}
10061016
if (username == null || password == null) {
10071017
throw new IllegalArgumentException("username and password must not be null");
10081018
}
@@ -1020,13 +1030,46 @@ public QwpQueryClient withBasicAuth(String username, String password) {
10201030
*/
10211031
public QwpQueryClient withBearerToken(String token) {
10221032
checkPreConnect("withBearerToken");
1033+
if (tokenProvider != null) {
1034+
throw new IllegalStateException("withBearerToken cannot be combined with withBearerTokenProvider");
1035+
}
10231036
if (token == null) {
10241037
throw new IllegalArgumentException("token must not be null");
10251038
}
10261039
this.authorizationHeader = "Bearer " + token;
10271040
return this;
10281041
}
10291042

1043+
/**
1044+
* Configures HTTP Bearer authentication with a token supplied on demand by
1045+
* {@code provider}, instead of the fixed token captured once by
1046+
* {@link #withBearerToken(String)}. The provider is queried for a fresh
1047+
* token at every WebSocket upgrade -- the initial {@link #connect()} and
1048+
* each failover reconnect -- so a long-lived client keeps working as the
1049+
* token rotates (for example an OIDC device-flow token:
1050+
* {@code .withBearerTokenProvider(auth::getToken)}).
1051+
* <p>
1052+
* {@link HttpTokenProvider#getToken()} runs on the connect and reconnect
1053+
* paths, so it must return promptly and must not block on interactive
1054+
* input; a quick silent refresh is fine. Each returned token is validated
1055+
* ({@link HttpTokenProvider#validateToken(CharSequence)}) before it is sent,
1056+
* and a provider that throws fails that connection attempt. Mutually
1057+
* exclusive with {@link #withBearerToken(String)} and
1058+
* {@link #withBasicAuth(String, String)}. Must be called before
1059+
* {@link #connect}.
1060+
*/
1061+
public QwpQueryClient withBearerTokenProvider(HttpTokenProvider provider) {
1062+
checkPreConnect("withBearerTokenProvider");
1063+
if (provider == null) {
1064+
throw new IllegalArgumentException("provider must not be null");
1065+
}
1066+
if (authorizationHeader != null) {
1067+
throw new IllegalStateException("withBearerTokenProvider cannot be combined with withBearerToken or withBasicAuth");
1068+
}
1069+
this.tokenProvider = provider;
1070+
return this;
1071+
}
1072+
10301073
/**
10311074
* Overrides the default I/O buffer pool depth (4). Larger pools let the
10321075
* I/O thread decode further ahead of the consumer at the cost of memory;
@@ -1744,11 +1787,27 @@ private void reconnectViaTracker() {
17441787
+ ", lastError=" + (lastError == null ? "<none>" : lastError.getMessage()) + ']');
17451788
}
17461789

1790+
private String resolveAuthorizationHeader() {
1791+
// With a token provider, re-query it at each upgrade so a reconnect
1792+
// presents a freshly refreshed token; validateToken rejects a
1793+
// null/empty/blank return, or one carrying a control or non-ASCII
1794+
// character, before it reaches the "Bearer " header. A provider that
1795+
// throws (a failed silent refresh) propagates and fails this connection
1796+
// attempt, matching the QWP ingress sender.
1797+
if (tokenProvider != null) {
1798+
CharSequence token = tokenProvider.getToken();
1799+
HttpTokenProvider.validateToken(token);
1800+
return "Bearer " + token;
1801+
}
1802+
return authorizationHeader;
1803+
}
1804+
17471805
private void runUpgradeWithTimeout(Endpoint ep) {
17481806
int timeoutMs = (int) Math.min(authTimeoutMs, Integer.MAX_VALUE);
1807+
String authHeader = resolveAuthorizationHeader();
17491808
try {
17501809
webSocketClient.connect(ep.host, ep.port);
1751-
webSocketClient.upgrade(DEFAULT_ENDPOINT_PATH, timeoutMs, authorizationHeader);
1810+
webSocketClient.upgrade(DEFAULT_ENDPOINT_PATH, timeoutMs, authHeader);
17521811
} catch (HttpClientException ex) {
17531812
if (ex.isTimeout()) {
17541813
HttpClientException timeout = new HttpClientException("WebSocket upgrade to ")

0 commit comments

Comments
 (0)