Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
86d65ff
feat(core): OIDC device flow
glasstiger Jun 17, 2026
ca59c01
Merge branch 'main' into ia_oidc_device_flow
glasstiger Jun 17, 2026
c92ee56
Sanitize bidi in OIDC prompt, defer token pull
glasstiger Jun 17, 2026
c3a4749
Harden OIDC parser: null, port range, token TTL
glasstiger Jun 17, 2026
4048273
Merge branch 'main' into ia_oidc_device_flow
glasstiger Jun 18, 2026
2e08fa8
Merge branch 'ia_oidc_device_flow' of https://github.com/questdb/java…
glasstiger Jun 18, 2026
2a1ce7d
fix test
glasstiger Jun 18, 2026
c036642
Fix sender corruption when the token provider throws
glasstiger Jun 18, 2026
eadc63f
Stop getTokenSilently blocking the flush path
glasstiger Jun 18, 2026
58920aa
Sanitize display text per code point
glasstiger Jun 18, 2026
1db99c1
Reject tokens from error or non-2xx responses
glasstiger Jun 18, 2026
c0ff593
Reject a null or empty provider token
glasstiger Jun 18, 2026
9824d69
improved tests
glasstiger Jun 18, 2026
faa6e47
Speed up JSON unescape and validate URL hosts
glasstiger Jun 18, 2026
19a9966
Add OIDC issuer pin and .well-known discovery
glasstiger Jun 18, 2026
dc02c16
Validate OIDC URLs and enforce the discoveryUrl pin
glasstiger Jun 19, 2026
e523db2
Reject display-unsafe characters in OIDC URLs
glasstiger Jun 19, 2026
caa5087
Strip unpaired surrogates from OIDC display text
glasstiger Jun 19, 2026
7266d47
Clamp slow_down interval and reset parser fields
glasstiger Jun 19, 2026
6f02ccf
Simplify JSON unescape and tidy method ordering
glasstiger Jun 19, 2026
9da26c1
Test the OIDC response body size cap
glasstiger Jun 19, 2026
697f49a
Harden OIDC device-flow status and timeout checks
glasstiger Jun 19, 2026
6e97d14
Pin OIDC discovery to the discoveryUrl origin
glasstiger Jun 21, 2026
4dbce9e
Reject a non-numeric OIDC HTTP status code
glasstiger Jun 21, 2026
ddd3e62
Escape control chars in ILP error messages
glasstiger Jun 21, 2026
c0ed8b4
Test the plaintext-channel OIDC pin firing path
glasstiger Jun 21, 2026
8421148
Fix OIDC Windows test, use try-with-resources
glasstiger Jun 22, 2026
49becd9
Reject OIDC tokens with control or non-ASCII chars
glasstiger Jun 22, 2026
bd37dc8
Bound chunked response reads to the call timeout
glasstiger Jun 22, 2026
64933dc
Escape bidi and format chars in error messages
glasstiger Jun 22, 2026
619a3fc
Tighten OIDC device-flow comments and javadoc
glasstiger Jun 22, 2026
7b9d20f
Add OIDC browser-open prompt and DiscoveryOptions
glasstiger Jun 23, 2026
6ac442b
Make OIDC browser-open the default
glasstiger Jun 23, 2026
2126e22
Send OIDC audience on device and refresh requests
glasstiger Jun 23, 2026
b8f073e
Tighten OIDC IdP transport and issuer-path trust
glasstiger Jun 23, 2026
0a31d49
Clamp OIDC device-code lifetime to 600s/1800s
glasstiger Jun 23, 2026
28dc110
reduce max poll interval to 60s
glasstiger Jun 23, 2026
8e721d4
Treat OIDC token-poll 429 as a transient backoff
glasstiger Jun 23, 2026
62403f3
Remove OIDC poll-error budget; match Python model
glasstiger Jun 23, 2026
f0cd84f
Accept token provider over WebSocket transport
glasstiger Jun 23, 2026
63487c1
Make OIDC clock skew fixed and lifetime-capped
glasstiger Jun 23, 2026
aab512b
Harden client response reads and display escaping
glasstiger Jun 23, 2026
4430a50
Fix HTTP client leak on lexer alloc failure
glasstiger Jun 23, 2026
a654fbb
Sort static helpers and pre-encode grant types
glasstiger Jun 23, 2026
0865d0e
Drop the OIDC connection on a bounded-read abort
glasstiger Jun 24, 2026
7d52ad5
Harden OIDC URL parsing and address review nits
glasstiger Jun 24, 2026
94da999
Reject control/non-ASCII chars in provider tokens
glasstiger Jun 24, 2026
15067f7
Fix build-time pull claim in token provider docs
glasstiger Jun 24, 2026
67c78d9
Harden issuer-path scope and fix review nits
glasstiger Jun 24, 2026
d5bcf93
Escape control/bidi chars in flush error messages
glasstiger Jun 24, 2026
23b6656
Unify display-safety classifier and add tests
glasstiger Jun 24, 2026
cea40a5
Trust discovered OIDC endpoints; drop discoveryUrl
glasstiger Jun 25, 2026
5dd82b4
Merge remote-tracking branch 'origin/main' into ia_oidc_device_flow
glasstiger Jun 25, 2026
c35d931
Fix Java 8 build breaks in OIDC device flow
glasstiger Jun 25, 2026
0493b4c
Sanitize HTTP status and probe text in errors
glasstiger Jun 25, 2026
a4875f2
Tighten OIDC token-kind and status validation
glasstiger Jun 25, 2026
e1f3d53
Fix Java 8 build: replace String.repeat in test
glasstiger Jun 25, 2026
3d10a4e
QWP egress token provider and OIDC API rename
glasstiger Jun 25, 2026
8d38d4f
OIDC device-flow review follow-ups
glasstiger Jun 25, 2026
272d704
Fix Java 8 build: use URLEncoder String charset
glasstiger Jun 25, 2026
1b7fecd
Fail fast on QWP token-provider failures
glasstiger Jun 25, 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
74 changes: 74 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,80 @@ try (Sender sender = Sender.fromConfig("https::addr=localhost:9000;tls_verify=un
}
```

### OIDC Sign-In (Device Flow)

For QuestDB Enterprise instances secured with OIDC, `OidcDeviceAuth` signs a user in interactively using the [OAuth 2.0 Device Authorization Grant](https://www.rfc-editor.org/rfc/rfc8628). It works from environments that have no local browser — a remote notebook kernel, a container, a headless job — because the user authorizes on any device (laptop or phone) while the process only makes outbound calls to the identity provider.

On first use it prints a verification URL and a short code, and opens the URL in your default browser when one is available; authorize there (or open the URL on any device, such as your phone), enter the code, and the token is cached in memory and refreshed silently on later calls.

```java
import io.questdb.client.Sender;
import io.questdb.client.cutlass.auth.OidcDeviceAuth;

// Discover the client id, scope and endpoints from the QuestDB server's /settings:
try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.com:9000")) {
auth.signIn(); // sign in once: prompts on first use, then caches and refreshes

// Pass a token provider, not a fixed string: the sender pulls a freshly refreshed token on each
// request, so a long-lived sender keeps working as the token rotates. getToken() refreshes
// silently and never prompts on the flush path.
try (Sender sender = Sender.builder(Sender.Transport.HTTP)
.address("questdb.example.com:9000")
.enableTls()
.httpTokenProvider(auth::getToken)
.build()) {
sender.table("trades")
.symbol("symbol", "ETH-USD")
.doubleColumn("price", 2615.54)
.atNow();
}
}
```

Prefer `httpTokenProvider(auth::getToken)` for a long-lived sender: it pulls a freshly refreshed token on every request, so the sender keeps working as the token rotates. A fixed `httpToken(token)` captures the token once, so a sender that outlives the token's lifetime starts failing with 401s. Either way, hand the token to the client through the builder (or the header/password fields below), not by embedding it in a `Sender.fromConfig(...)` string or the `QDB_CLIENT_CONF` environment variable, which are easily logged, persisted, or left in shell history.

By default the prompt prints the verification URL and code to `System.out` **and** tries to open the URL in your default browser. The browser open is best-effort: it only opens an `http(s)` URL, is skipped on a headless host or a JVM without the `java.desktop` module, and never blocks sign-in — the URL and code are always printed too, so a remote or browserless process still works. To disable the browser launch for a whole process (a server, automation, CI), set the system property `-Dquestdb.client.oidc.open.browser=false`. To print only (no browser) for a single client, pass `DeviceCodePrompt.SYSTEM_OUT`; to render the challenge yourself (a clickable link or QR code in a notebook), pass any `DeviceCodePrompt`:

```java
// print only, do not open a browser:
try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(
"https://questdb.example.com:9000",
new OidcDeviceAuth.DiscoveryOptions().prompt(DeviceCodePrompt.SYSTEM_OUT))) {
auth.signIn();
}
```

The same token can be presented to QuestDB over any auth path the server already validates:

- **REST API:** send it as an `Authorization: Bearer <token>` header (`auth.getAuthorizationHeaderValue()` returns the full value).
- **PG-wire:** connect as user `_sso` with the token as the password (requires `acl.oidc.pg.token.as.password.enabled=true` on the server).

To configure the identity provider explicitly instead of discovering it from the server:

```java
OidcDeviceAuth auth = OidcDeviceAuth.builder()
.clientId("questdb")
.deviceAuthorizationEndpoint("https://idp.example.com/as/device_authz.oauth2")
.tokenEndpoint("https://idp.example.com/as/token.oauth2")
.scope("openid groups")
.groupsInToken(true) // matches acl.oidc.groups.encoded.in.token on the server
.build();
```

Discovery via `fromQuestDB(...)` reads the OIDC client id, scope, audience and endpoints from the server's `/settings`, and the identity provider's client must have the device authorization grant enabled. When the server does not advertise its device authorization endpoint (today's servers), pin the identity provider by its issuer so the client can discover the endpoint from the issuer's `.well-known/openid-configuration` document:

```java
try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(
"https://questdb.example.com:9000",
new OidcDeviceAuth.DiscoveryOptions().issuer("https://idp.example.com"))) {
auth.signIn();
}
```

The identity provider's device authorization and token endpoints must use `https` — a loopback endpoint (`localhost` or `127.0.0.0/8`) may use `http`, since the request never leaves the host — so the device code and refresh token are never sent in cleartext. `allowInsecureTransport(true)` relaxes only the QuestDB `/settings` link (for local development against an `http` QuestDB server), e.g. `OidcDeviceAuth.fromQuestDB(url, new OidcDeviceAuth.DiscoveryOptions().allowInsecureTransport(true))`; it never relaxes the identity provider endpoints, matching the Python client.

`fromQuestDB(...)` takes the identity provider endpoints from the server's unauthenticated `/settings`, so it trusts that server to designate where you sign in: a spoofed, compromised, or man-in-the-middled server could redirect the sign-in to an attacker-controlled identity provider. Only use it against a server you trust, reached over `https`. Passing an issuer hardens this: the token and device authorization endpoints are then pinned to the issuer's origin (and, when the issuer has a path, an endpoint advertised by `/settings` must also be under that path — so a tampered `/settings` cannot redirect to a different tenant on a path-based provider such as Keycloak `…/realms/{realm}`), and an endpoint outside it is rejected; the issuer itself comes from you out of band, so a tampered `/settings` cannot move it. When the server is not trusted, configure the identity provider explicitly with `OidcDeviceAuth.builder()` (optionally with `.issuer(...)`) instead of discovering it.

### Explicit Timestamps

```java
Expand Down
80 changes: 80 additions & 0 deletions core/src/main/java/io/questdb/client/HttpTokenProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*+*****************************************************************************
* ___ _ ____ ____
* / _ \ _ _ ___ ___| |_| _ \| __ )
* | | | | | | |/ _ \/ __| __| | | | _ \
* | |_| | |_| | __/\__ \ |_| |_| | |_) |
* \__\_\\__,_|\___||___/\__|____/|____/
*
* Copyright (c) 2014-2019 Appsicle
* Copyright (c) 2019-2026 QuestDB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
******************************************************************************/

package io.questdb.client;

import io.questdb.client.cutlass.line.LineSenderException;
import io.questdb.client.std.Chars;

/**
* Supplies an HTTP authentication token to a {@link Sender} on demand, so a provider returning a
* freshly refreshed token - e.g. {@code OidcDeviceAuth::getToken} - keeps a long-lived sender
* authenticated as the token rotates, without rebuilding it. Over HTTP the sender calls
* {@link #getToken()} as it builds each request; over WebSocket it calls it once per connection
* handshake, on the initial connect and again on every reconnect.
* <p>
* {@link #getToken()} runs on the sender's flush and reconnect paths: it must return promptly and must
* not block on interactive input. A quick silent token refresh is fine, but it must not start an
* interactive sign-in. An exception from {@link #getToken()} fails the in-flight flush (HTTP) or the
* connection attempt (WebSocket).
*
* @see Sender.LineSenderBuilder#httpTokenProvider(HttpTokenProvider)
*/
@FunctionalInterface
public interface HttpTokenProvider {
/**
* Validates a token returned by {@link #getToken()} before the sender writes it into an
* {@code Authorization: Bearer} header. Rejects a null, empty or blank token, and any token
* carrying a control or non-ASCII character (outside {@code 0x20}-{@code 0x7e}): a real bearer
* token is printable ASCII, so a stray CR/LF (which would inject into the HTTP request line) or a
* non-ASCII byte (silently truncated to one byte by the ASCII header writer, yielding a corrupt
* credential the server only answers with 401) is refused rather than sent. The token itself is
* never placed in the exception message - it is the secret this guards.
*
* @param token the token returned by a provider
* @throws LineSenderException if the token is null, empty, blank, or carries a control or
* non-ASCII character
*/
static void validateToken(CharSequence token) {
if (Chars.isBlank(token)) {
throw new LineSenderException("token provider returned a null or empty token");
}
for (int i = 0, n = token.length(); i < n; i++) {
char c = token.charAt(i);
if (c < 0x20 || c > 0x7e) {
throw new LineSenderException("token provider returned a token containing a control or non-ASCII character; refusing to send it as a credential");
}
}
}

/**
* Returns the current HTTP authentication token, without the {@code "Bearer "} prefix (the sender
* adds it). Must not return null or empty, and must contain only printable ASCII (no control or
* non-ASCII characters) - the sender splices the value verbatim into an {@code Authorization:
* Bearer} header and rejects a token that violates this (see {@link #validateToken(CharSequence)}).
*
* @return the current HTTP authentication token
*/
CharSequence getToken();
}
79 changes: 74 additions & 5 deletions core/src/main/java/io/questdb/client/Sender.java
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
import java.util.Base64;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;

/**
* Influx Line Protocol client to feed data to a remote QuestDB instance.
Expand Down Expand Up @@ -1045,6 +1046,7 @@ final class LineSenderBuilder {
private String httpSettingsPath;
private int httpTimeout = PARAMETER_NOT_SET_EXPLICITLY;
private String httpToken;
private HttpTokenProvider httpTokenProvider;
// Drives the initial-connect strategy. null means "not set
// explicitly", which build() resolves to SYNC when any reconnect_*
// knob was tuned by the user, otherwise OFF. SYNC retries on the
Expand Down Expand Up @@ -1376,7 +1378,7 @@ public Sender build() {
tlsConfig = new ClientTlsConfiguration(trustStorePath, trustStorePassword, tlsValidationMode == TlsValidationMode.DEFAULT ? ClientTlsConfiguration.TLS_VALIDATION_MODE_FULL : ClientTlsConfiguration.TLS_VALIDATION_MODE_NONE);
}
return AbstractLineHttpSender.createLineSender(hosts, ports, httpPath, httpClientConfiguration, tlsConfig, actualAutoFlushRows, httpToken,
username, password, maxNameLength, actualMaxRetriesNanos, maxBackoffMillis, actualMinRequestThroughput, actualAutoFlushIntervalMillis, protocolVersion);
username, password, maxNameLength, actualMaxRetriesNanos, maxBackoffMillis, actualMinRequestThroughput, actualAutoFlushIntervalMillis, protocolVersion, httpTokenProvider);
}

if (protocol == PROTOCOL_WEBSOCKET) {
Expand All @@ -1390,7 +1392,7 @@ public Sender build() {
? DEFAULT_WS_AUTO_FLUSH_INTERVAL_NANOS
: TimeUnit.MILLISECONDS.toNanos(autoFlushIntervalMillis);

String wsAuthHeader = buildWebSocketAuthHeader();
Supplier<String> wsAuthHeader = buildWebSocketAuthHeader();

ClientTlsConfiguration wsTlsConfig = null;
if (tlsEnabled) {
Expand Down Expand Up @@ -2003,13 +2005,57 @@ public LineSenderBuilder httpToken(String token) {
if (this.httpToken != null) {
throw new LineSenderException("token was already configured");
}
if (this.httpTokenProvider != null) {
throw new LineSenderException("token provider was already configured");
}
if (Chars.isBlank(token)) {
throw new LineSenderException("token cannot be empty nor null");
}
this.httpToken = token;
return this;
}

/**
* Supplies the HTTP authentication token from a provider queried as the sender builds each request,
* instead of a fixed {@link #httpToken(String) token} captured once, so a long-lived sender follows
* token refreshes - e.g. an OIDC device-flow token: {@code .httpTokenProvider(auth::getToken)}.
* <br>
* Over HTTP the provider is not called at build time: the first call happens when the first row is
* started, then once per flush. Over WebSocket the initial connection handshake runs during
* {@code build()} and queries the provider once for it, then again once per reconnect handshake - so a
* refreshed token is presented each time the link is (re)established; an already-established WebSocket
* is not re-authenticated mid-stream. The two transports differ on a sustained token outage: over HTTP
* a failed pull is retried on the next row, but over WebSocket a pull that keeps failing past the
* reconnect budget terminates the sender for good, like any persistent reconnect failure. A
* lazily-signing-in provider can therefore be wired before the interactive sign-in completes over HTTP,
* where the first pull is deferred to the first row; over WebSocket a token must already be obtainable
* when {@code build()} runs, since the initial handshake pulls it - otherwise that {@code build()} (or,
* over HTTP, the first row) fails. Running on the send/flush and reconnect paths, the provider must
* return promptly and must not block on interactive input (see {@link HttpTokenProvider}). Supported
* over HTTP and WebSocket transport, and mutually exclusive with {@link #httpToken(String)} and
* {@link #httpUsernamePassword(String, String)}.
*
* @param httpTokenProvider supplies the current HTTP authentication token
* @return this instance for method chaining
*/
public LineSenderBuilder httpTokenProvider(HttpTokenProvider httpTokenProvider) {
if (this.username != null) {
throw new LineSenderException("authentication username was already configured ")
.put("[username=").put(this.username).put("]");
}
if (this.httpToken != null) {
throw new LineSenderException("token was already configured");
}
if (this.httpTokenProvider != null) {
throw new LineSenderException("token provider was already configured");
}
if (httpTokenProvider == null) {
throw new LineSenderException("token provider cannot be null");
}
this.httpTokenProvider = httpTokenProvider;
return this;
}

/**
* Use username and password for authentication when communicating over HTTP or WebSocket protocol.
* <br>
Expand All @@ -2035,6 +2081,9 @@ public LineSenderBuilder httpUsernamePassword(String username, String password)
if (httpToken != null) {
throw new LineSenderException("token authentication is already configured");
}
if (httpTokenProvider != null) {
throw new LineSenderException("token provider authentication is already configured");
}
this.username = username;
this.password = password;
return this;
Expand Down Expand Up @@ -2867,13 +2916,27 @@ private void appendAddress(String host, int port) {
ports.add(port);
}

private String buildWebSocketAuthHeader() {
private Supplier<String> buildWebSocketAuthHeader() {
if (username != null && password != null) {
String credentials = username + ":" + password;
return "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
String header = "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
return () -> header;
}
if (httpToken != null) {
return "Bearer " + httpToken;
String header = "Bearer " + httpToken;
return () -> header;
}
if (httpTokenProvider != null) {
// pull a fresh token at each (re)handshake so a long-lived WebSocket follows token
// refreshes; validateToken rejects a null/empty/blank return, or a token carrying a
// control or non-ASCII char (both forbidden by the HttpTokenProvider contract), rather
// than send a malformed or CR/LF-injected "Bearer " header
final HttpTokenProvider provider = httpTokenProvider;
return () -> {
CharSequence token = provider.getToken();
HttpTokenProvider.validateToken(token);
return "Bearer " + token;
};
}
return null;
}
Expand Down Expand Up @@ -3799,6 +3862,9 @@ private void validateParameters() {
if (httpToken != null) {
throw new LineSenderException("HTTP token authentication is not supported for TCP protocol");
}
if (httpTokenProvider != null) {
throw new LineSenderException("HTTP token provider authentication is not supported for TCP protocol");
}
if (retryTimeoutMillis != PARAMETER_NOT_SET_EXPLICITLY) {
throw new LineSenderException("retrying is not supported for TCP protocol");
}
Expand All @@ -3824,6 +3890,9 @@ private void validateParameters() {
if (httpToken != null) {
throw new LineSenderException("HTTP token authentication is not supported for UDP transport");
}
if (httpTokenProvider != null) {
throw new LineSenderException("HTTP token provider authentication is not supported for UDP transport");
}
if (username != null || password != null) {
throw new LineSenderException("username/password authentication is not supported for UDP transport");
}
Expand Down
Loading
Loading