Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 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
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
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,68 @@ 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; open the URL, 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.getToken(); // 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. getTokenSilently() 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::getTokenSilently)
.build()) {
sender.table("trades")
.symbol("symbol", "ETH-USD")
.doubleColumn("price", 2615.54)
.atNow();
}
}
```

Prefer `httpTokenProvider(auth::getTokenSilently)` 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.

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 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", "https://idp.example.com")) {
auth.getToken();
}
```

By default the device authorization and token endpoints must use `https`, so tokens are never sent in cleartext; an `http` endpoint is rejected. For local development against an `http` endpoint, opt in explicitly with `.allowInsecureTransport(true)` on the builder, or `OidcDeviceAuth.fromQuestDB(url, true)`.

`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 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
48 changes: 48 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,48 @@
/*+*****************************************************************************
* ___ _ ____ ____
* / _ \ _ _ ___ ___| |_| _ \| __ )
* | | | | | | |/ _ \/ __| __| | | | _ \
* | |_| | |_| | __/\__ \ |_| |_| | |_) |
* \__\_\\__,_|\___||___/\__|____/|____/
*
* 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;

/**
* Supplies an HTTP authentication token to a {@link Sender} on demand. The sender calls
* {@link #getToken()} as it builds each request, so a provider that returns a freshly refreshed
* token - for example {@code OidcDeviceAuth::getTokenSilently} - keeps a long-lived sender
* authenticated as the token rotates, without rebuilding the sender.
* <p>
* {@link #getToken()} runs on the sender's flush path, so it must return promptly and must not
* block on interactive input. It may perform a quick silent token refresh, but must not start an
* interactive sign-in. An exception thrown from {@link #getToken()} fails the current flush.
*
* @see Sender.LineSenderBuilder#httpTokenProvider(HttpTokenProvider)
*/
@FunctionalInterface
public interface HttpTokenProvider {
/**
* Returns the current HTTP authentication token, without the {@code "Bearer "} prefix (the
* sender adds it). Must not return null or an empty value.
*
* @return the current HTTP authentication token
*/
CharSequence getToken();
}
53 changes: 52 additions & 1 deletion core/src/main/java/io/questdb/client/Sender.java
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,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 @@ -1365,7 +1366,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 Down Expand Up @@ -1998,13 +1999,51 @@ 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 that the sender queries as it builds
* each request, instead of a fixed {@link #httpToken(String) token} captured once. This keeps a
* long-lived sender following token refreshes - for example a token obtained through the OIDC
* device flow: {@code .httpTokenProvider(auth::getTokenSilently)}.
* <br>
* The sender does not call the provider at build time: the first call happens when the first row
* is started, then once per flush. A provider that signs in lazily can therefore be wired before
* the interactive sign-in completes, as long as a token is obtainable before the first row is
* added - otherwise that first row fails. The provider runs on the flush path, so it must return
* promptly and must not block on interactive input (see {@link HttpTokenProvider}). Only valid for
* HTTP 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 @@ -2030,6 +2069,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 @@ -3435,6 +3477,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 @@ -3460,6 +3505,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 Expand Up @@ -3503,6 +3551,9 @@ private void validateParameters() {
if (httpToken != null && (username != null || password != null)) {
throw new LineSenderException("cannot use both token and username/password authentication");
}
if (httpTokenProvider != null) {
throw new LineSenderException("HTTP token provider authentication is not supported for WebSocket protocol");
}
if (httpPath != null) {
throw new LineSenderException("HTTP path is not supported for WebSocket protocol");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*+*****************************************************************************
* ___ _ ____ ____
* / _ \ _ _ ___ ___| |_| _ \| __ )
* | | | | | | |/ _ \/ __| __| | | | _ \
* | |_| | |_| | __/\__ \ |_| |_| | |_) |
* \__\_\\__,_|\___||___/\__|____/|____/
*
* 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.cutlass.auth;

/**
* The user-facing part of an RFC 8628 device authorization response: the code the
* user has to type and the URL where they type it. A {@link DeviceCodePrompt}
* receives this object and is responsible for showing it to the user.
* <p>
* The {@code device_code} secret is deliberately not exposed here; it stays inside
* {@link OidcDeviceAuth} and is never shown to the user.
*/
public class DeviceAuthorizationChallenge {
private final int expiresInSeconds;
private final int intervalSeconds;
private final String userCode;
private final String verificationUri;
private final String verificationUriComplete;

public DeviceAuthorizationChallenge(
String userCode,
String verificationUri,
String verificationUriComplete,
int expiresInSeconds,
int intervalSeconds
) {
this.userCode = userCode;
this.verificationUri = verificationUri;
this.verificationUriComplete = verificationUriComplete;
this.expiresInSeconds = expiresInSeconds;
this.intervalSeconds = intervalSeconds;
}

/**
* @return how long, in seconds, the {@link #getUserCode() user code} stays valid.
*/
public int getExpiresInSeconds() {
return expiresInSeconds;
}

/**
* @return the minimum number of seconds the client must wait between polls.
*/
public int getIntervalSeconds() {
return intervalSeconds;
}

/**
* @return the code the user has to enter at the {@link #getVerificationUri() verification URL}.
*/
public String getUserCode() {
return userCode;
}

/**
* @return the URL the user has to open to authorize the device.
*/
public String getVerificationUri() {
return verificationUri;
}

/**
* @return a URL that already embeds the user code, so the user does not have to type it,
* or {@code null} when the identity provider does not supply one.
*/
public String getVerificationUriComplete() {
return verificationUriComplete;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*+*****************************************************************************
* ___ _ ____ ____
* / _ \ _ _ ___ ___| |_| _ \| __ )
* | | | | | | |/ _ \/ __| __| | | | _ \
* | |_| | |_| | __/\__ \ |_| |_| | |_) |
* \__\_\\__,_|\___||___/\__|____/|____/
*
* 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.cutlass.auth;

import io.questdb.client.std.str.StringSink;

/**
* Shows an RFC 8628 device authorization challenge to the user, who then opens the
* verification URL in any browser (on the same machine or on a phone) and enters the
* code. {@link OidcDeviceAuth} calls this once per interactive sign-in, just before it
* starts polling the token endpoint.
* <p>
* The {@link #SYSTEM_OUT default implementation} prints the instructions to
* {@code System.out}. Supply your own implementation to render the challenge somewhere
* else, for example as a clickable link or a QR code in a notebook.
*/
@FunctionalInterface
public interface DeviceCodePrompt {

/**
* Prints the sign-in instructions to {@code System.out} using plain ASCII text.
*/
DeviceCodePrompt SYSTEM_OUT = challenge -> {
String newLine = System.lineSeparator();
StringSink sb = new StringSink();
sb.put(newLine);
sb.put("=== QuestDB OIDC sign-in ===").put(newLine);
sb.put("To sign in, open this URL in a browser:").put(newLine);
sb.put(" ").put(challenge.getVerificationUri()).put(newLine);
sb.put("and enter the code: ").put(challenge.getUserCode()).put(newLine);
if (challenge.getVerificationUriComplete() != null) {
sb.put("(or open this URL, the code is already filled in:").put(newLine);
sb.put(" ").put(challenge.getVerificationUriComplete()).put(')').put(newLine);
}
sb.put("Waiting for authorization, up to ").put(challenge.getExpiresInSeconds()).put(" seconds...");
System.out.println(sb);
};

/**
* Shows the challenge to the user. This method must return quickly; the actual waiting
* for the user happens afterwards while {@link OidcDeviceAuth} polls the token endpoint.
*
* @param challenge the user code, verification URL and timing parameters to show
*/
void promptUser(DeviceAuthorizationChallenge challenge);
}
Loading
Loading